Skip to main content

hyalo_cli/
output.rs

1use std::collections::HashMap;
2use std::fmt::Write as _;
3
4use jaq_core::load::{self, Arena, File, Loader};
5use jaq_core::{Compiler, Ctx, Native, Vars, data};
6use jaq_json::Val;
7use serde::Serialize;
8use serde_json::json;
9
10// ---------------------------------------------------------------------------
11// Filter cache
12// ---------------------------------------------------------------------------
13
14/// The `DataT` implementation used for jaq filter compilation and execution.
15///
16/// `JustLut<Val>` is a minimal wrapper that only provides the compiled lookup
17/// table — sufficient because we don't use lifetime-dependent filters like
18/// `inputs`.
19type D = data::JustLut<Val>;
20
21/// Cache of compiled jaq filters, keyed by filter source string.
22///
23/// The compiled `Filter` is fully owned (no lifetime parameters) and `Clone`,
24/// so it can be stored directly in a `HashMap`. The `Arena` used during
25/// `Loader::load` is a temporary scratch pad — once `compile` returns, the
26/// `Filter` no longer borrows from it.
27type JaqFilterCache = HashMap<String, jaq_core::compile::Filter<Native<D>>>;
28
29/// Output format.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
31pub enum Format {
32    Json,
33    Text,
34}
35
36impl std::fmt::Display for Format {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Format::Json => f.write_str("json"),
40            Format::Text => f.write_str("text"),
41        }
42    }
43}
44
45/// Result of a command execution: either success (exit 0) or a user-facing error (exit 1).
46/// Internal/unexpected errors are represented by `anyhow::Error` at the call site.
47///
48/// **Invariant**: `Success.output` must always be a valid JSON string — the pipeline handles
49/// format conversion. Commands must never store pre-formatted text here.
50///
51/// For commands like `read` whose text output is raw file content (not structured data),
52/// use `RawOutput` to bypass the JSON pipeline entirely.
53#[derive(Debug)]
54pub enum CommandOutcome {
55    /// Successful operation — JSON output goes to stdout via the pipeline.
56    Success {
57        /// Always-valid JSON string (bare array, object, etc.). Never pre-formatted text.
58        output: String,
59        /// Optional total item count for pagination display.
60        total: Option<u64>,
61    },
62    /// Raw text output, bypasses the JSON pipeline — printed directly to stdout as-is.
63    /// Used by `read` command for text-format content output.
64    RawOutput(String),
65    /// User error (file not found, property missing, etc.) — output goes to stderr.
66    UserError(String),
67}
68
69impl CommandOutcome {
70    /// Construct a successful outcome carrying a JSON string with no total count.
71    #[must_use]
72    pub fn success(output: String) -> Self {
73        Self::Success {
74            output,
75            total: None,
76        }
77    }
78
79    /// Construct a successful outcome carrying a JSON string with a total item count.
80    #[must_use]
81    pub fn success_with_total(output: String, total: u64) -> Self {
82        Self::Success {
83            output,
84            total: Some(total),
85        }
86    }
87
88    /// Extract the output string from `Success` or `RawOutput`, or panic.
89    ///
90    /// Intended for use in unit tests where the command is expected to succeed.
91    #[cfg(test)]
92    #[must_use]
93    pub fn unwrap_output(self) -> String {
94        match self {
95            Self::Success { output, .. } | Self::RawOutput(output) => output,
96            Self::UserError(msg) => panic!("expected success, got UserError: {msg}"),
97        }
98    }
99}
100
101impl Format {
102    #[must_use]
103    pub fn from_str_opt(s: &str) -> Option<Self> {
104        match s {
105            "json" => Some(Self::Json),
106            "text" => Some(Self::Text),
107            _ => None,
108        }
109    }
110}
111
112/// Format a successful JSON value for output.
113#[must_use]
114pub fn format_success(format: Format, value: &serde_json::Value) -> String {
115    match format {
116        Format::Json => serde_json::to_string_pretty(value)
117            .expect("serializing serde_json::Value is infallible"),
118        Format::Text => {
119            let mut cache = JaqFilterCache::new();
120            format_value_as_text(value, &mut cache)
121        }
122    }
123}
124
125/// Format any `Serialize` type for output.
126///
127/// Converts the value to `serde_json::Value` first so that the text formatter
128/// can operate on a uniform representation.
129#[must_use]
130pub fn format_output<T: Serialize>(format: Format, value: &T) -> String {
131    let json = serde_json::to_value(value).expect("derived Serialize impl should not fail");
132    format_success(format, &json)
133}
134
135/// Build the JSON envelope value: `{"results": ..., "total": <optional>, "hints": [...]}`.
136///
137/// The envelope is always present even when hints is empty (hints becomes `[]`).
138/// `total` is included only when `Some`.
139#[must_use]
140pub fn build_envelope_value(
141    value: &serde_json::Value,
142    total: Option<u64>,
143    hints: &[crate::hints::Hint],
144) -> serde_json::Value {
145    let hints_json: Vec<serde_json::Value> = hints
146        .iter()
147        .map(|h| serde_json::json!({"description": &h.description, "cmd": &h.cmd}))
148        .collect();
149    let mut envelope = serde_json::json!({
150        "results": value,
151        "hints": hints_json,
152    });
153    if let Some(t) = total {
154        envelope["total"] = serde_json::json!(t);
155    }
156    envelope
157}
158
159/// Format the output envelope for the user.
160///
161/// - **JSON**: serializes `{"results": ..., "total": <optional>, "hints": [...]}`
162/// - **Text**: formats `results` as text, appends hint lines if any, adds pagination notice if needed
163#[must_use]
164pub fn format_envelope(
165    format: Format,
166    value: &serde_json::Value,
167    total: Option<u64>,
168    hints: &[crate::hints::Hint],
169) -> String {
170    match format {
171        Format::Json => {
172            let envelope = build_envelope_value(value, total, hints);
173            serde_json::to_string_pretty(&envelope)
174                .expect("serializing serde_json::Value is infallible")
175        }
176        Format::Text => {
177            let mut cache = JaqFilterCache::new();
178            let mut text = format_results_as_text(value, total, &mut cache);
179            if !hints.is_empty() {
180                text.push('\n');
181                for hint in hints {
182                    text.push_str("\n  -> ");
183                    text.push_str(&hint.cmd);
184                    text.push_str("  # ");
185                    text.push_str(&hint.description);
186                }
187            }
188            text
189        }
190    }
191}
192
193/// Format results for text output, applying pagination notice and tag-summary header.
194///
195/// Called by [`format_envelope`] when producing text output. The `total` is the
196/// count stored in the envelope (may exceed the number of items in `results`).
197fn format_results_as_text(
198    results: &serde_json::Value,
199    total: Option<u64>,
200    cache: &mut JaqFilterCache,
201) -> String {
202    // Special case: array of tag summary entries ({count, name}) — reconstruct
203    // the "N unique tags" header that was previously part of the TAG_SUMMARY_FILTER.
204    if let (Some(total), serde_json::Value::Array(arr)) = (total, results) {
205        let is_tag_array = !arr.is_empty()
206            && arr.iter().all(|v| {
207                v.as_object().is_some_and(|m| {
208                    m.contains_key("count") && m.contains_key("name") && m.len() == 2
209                })
210            });
211        if is_tag_array {
212            let tag_label = if total == 1 { "tag" } else { "tags" };
213            let header = format!("{total} unique {tag_label}");
214            let entries = format_value_as_text(results, cache);
215            return if entries.is_empty() {
216                header
217            } else {
218                format!("{header}\n{entries}")
219            };
220        }
221    }
222
223    let text = format_value_as_text(results, cache);
224    if let Some(total) = total {
225        let shown = match results {
226            serde_json::Value::Array(arr) => arr.len() as u64,
227            _ => return text,
228        };
229        if shown < total {
230            return format!("{text}\nshowing {shown} of {total} matches");
231        }
232    }
233    text
234}
235
236/// Format an error for output to stderr.
237#[must_use]
238pub fn format_error(
239    format: Format,
240    error: &str,
241    path: Option<&str>,
242    hint: Option<&str>,
243    cause: Option<&str>,
244) -> String {
245    match format {
246        Format::Json => {
247            let mut obj = json!({"error": error});
248            if let Some(p) = path {
249                obj["path"] = json!(p);
250            }
251            if let Some(h) = hint {
252                obj["hint"] = json!(h);
253            }
254            if let Some(c) = cause {
255                obj["cause"] = json!(c);
256            }
257            serde_json::to_string_pretty(&obj).expect("serializing serde_json::Value is infallible")
258        }
259        Format::Text => {
260            let mut msg = format!("Error: {error}");
261            if let Some(p) = path {
262                let _ = write!(msg, "\n  path: {p}");
263            }
264            if let Some(h) = hint {
265                let _ = write!(msg, "\n  hint: {h}");
266            }
267            if let Some(c) = cause {
268                let _ = write!(msg, "\n  cause: {c}");
269            }
270            msg
271        }
272    }
273}
274
275// ---------------------------------------------------------------------------
276// jq filter constants — one per output type
277// ---------------------------------------------------------------------------
278
279/// `PropertyInfo` (used by `--fields properties-typed`): `{name, type, value}`
280/// When value is an array (list type), join elements with ", " for readability.
281const PROPERTY_INFO_FILTER: &str = r#""\(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | join(", ")) + "]" else .value end)""#;
282
283/// `PropertySummaryEntry`: `{count, name, type}`
284const PROPERTY_SUMMARY_ENTRY_FILTER: &str =
285    r#""\(.name)\t\(.type)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;
286
287/// `TagSummary`: `{tags, total}`
288const TAG_SUMMARY_FILTER: &str = r#""\(.total) unique \(if .total == 1 then "tag" else "tags" end)\n\(.tags | map("  \(.name)\t\(.count) \(if .count == 1 then "file" else "files" end)") | join("\n"))""#;
289
290/// `TagSummaryEntry`: `{count, name}`
291const TAG_SUMMARY_ENTRY_FILTER: &str =
292    r#""\(.name)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;
293
294/// `LinkInfo` — just target: `{target}`
295/// Format: `  "target" (unresolved)`
296const LINK_INFO_TARGET_FILTER: &str = r#""  \"\(.target)\" (unresolved)""#;
297
298/// `LinkInfo` with path: `{path, target}`
299/// Format: `  "target" → "path"`
300const LINK_INFO_PATH_FILTER: &str = r#""  \"\(.target)\" → \"\(.path)\"""#;
301
302/// `LinkInfo` with label: `{label, target}`
303/// Format: `  "target" (unresolved) [label]`
304const LINK_INFO_LABEL_FILTER: &str = r#""  \"\(.target)\" (unresolved) [\(.label)]""#;
305
306/// `LinkInfo` with path and label: `{label, path, target}`
307/// Format: `  "target" → "path" [label]`
308const LINK_INFO_FULL_FILTER: &str = r#""  \"\(.target)\" → \"\(.path)\" [\(.label)]""#;
309
310/// `TaskCount`: `{done, total}`
311const TASK_COUNT_FILTER: &str = r#""[\(.done)/\(.total)]""#;
312
313/// `OutlineSection` without tasks: `{code_blocks, heading, level, line, links}`
314const OUTLINE_SECTION_FILTER: &str = r##""\("#" * .level) \(.heading // "(pre-heading)")\(if (.links | length) > 0 then "\n\(.links | map("  → \"\(.)\"") | join("\n"))" else "" end)""##;
315
316/// `OutlineSection` with tasks: `{code_blocks, heading, level, line, links, tasks}`
317const OUTLINE_SECTION_WITH_TASKS_FILTER: &str = r##""\("#" * .level) \(.heading // "(pre-heading)") [\(.tasks.done)/\(.tasks.total)]\(if (.links | length) > 0 then "\n\(.links | map("  → \"\(.)\"") | join("\n"))" else "" end)""##;
318
319/// `TaskInfo`: `{done, line, status, text}`
320const TASK_INFO_FILTER: &str =
321    r#""line \(.line): [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;
322
323/// `TaskReadResult`: `{done, file, line, status, text}`
324const TASK_READ_RESULT_FILTER: &str =
325    r#""\"\(.file)\":\(.line) [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;
326
327/// `VaultSummary`: `{dead_ends, files, links, orphans, properties, recent_files, status, tags, tasks}`
328/// Compact single-line-per-section format (~20-30 lines regardless of vault size).
329const VAULT_SUMMARY_FILTER: &str = r#""Files: \(.files.total)\nDirectories: \(if (.files.directories | length) > 0 then (.files.directories | .[:7] | map("\(.directory)/ (\(.count))") | join(", ")) + (if (.files.directories | length) > 7 then ", ..." else "" end) else "(none)" end)\nProperties: \(.properties | length) — \(if (.properties | length) > 0 then (.properties | sort_by(-.count) | .[:7] | map("\(.name) (\(.count))") | join(", ")) + (if (.properties | length) > 7 then ", ..." else "" end) else "(none)" end)\nTags: \(.tags.total) — \(if (.tags.tags | length) > 0 then (.tags.tags | .[:7] | map("\(.name) (\(.count))") | join(", ")) + (if (.tags.tags | length) > 7 then ", ..." else "" end) else "(none)" end)\nTasks: \(.tasks.done)/\(.tasks.total)\nLinks: \(.links.total) total, \(.links.broken) broken\nOrphans: \(.orphans)\nDead-ends: \(.dead_ends)\nStatus: \(if (.status | length) > 0 then (.status | sort_by(-.count) | map("\(.value) (\(.count))") | join(", ")) else "(none)" end)\nRecent: \(if (.recent_files | length) > 0 then (.recent_files | map(.path) | join(", ")) else "(none)" end)""#;
330
331/// `FindTaskInfo`: `{done, line, section, status, text}`
332/// Format: `  [x] text (line N, section)` or `  [ ] text (line N, section)`
333const FIND_TASK_INFO_FILTER: &str =
334    r#""  [\(if .done then "x" else " " end)] \(.text) (line \(.line), \(.section))""#;
335
336/// `ContentMatch`: `{line, section, text}`
337/// Format: `  line N (section): text`
338const CONTENT_MATCH_FILTER: &str = r#""  line \(.line) (\(.section)): \(.text)""#;
339
340/// Mutation result with `property` + `value` fields:
341/// covers `SetPropertyResult`, `AppendPropertyResult`, and `RemovePropertyResult` (with value).
342/// Key signature: `dry_run,modified,property,scanned,skipped,total,value`
343/// Format: `[dry-run] property=value: N/T modified (S scanned)` when dry-run; omits prefix otherwise.
344/// Appends `(S scanned)` when not all scanned files were processed (e.g. where-filters).
345const PROPERTY_VALUE_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.property)=\(.value): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map("  \"\(.)\"") | join("\n"))" else "" end)""#;
346
347/// Mutation result with `property` only (no value field):
348/// covers `RemovePropertyResult` (without value).
349/// Key signature: `dry_run,modified,property,scanned,skipped,total`
350/// Format: `[dry-run] property: N/T modified (S scanned)` when dry-run; omits prefix otherwise.
351/// Appends `(S scanned)` when not all scanned files were processed (e.g. where-filters).
352const PROPERTY_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.property): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map("  \"\(.)\"") | join("\n"))" else "" end)""#;
353
354/// Mutation result with `tag` field:
355/// covers `SetTagResult` and `RemoveTagResult`.
356/// Key signature: `dry_run,modified,scanned,skipped,tag,total`
357/// Format: `[dry-run] tag: N/T modified (S scanned)` when dry-run; omits prefix otherwise.
358/// Appends `(S scanned)` when not all scanned files were processed (e.g. where-filters).
359const TAG_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.tag): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map("  \"\(.)\"") | join("\n"))" else "" end)""#;
360
361/// `BacklinksResult`: `{file, backlinks: [...]}`
362/// Format: `N backlink(s) for "file"` with each backlink listed as `  source.md: line N`.
363/// Empty case: `No backlinks found for "file"`.
364const BACKLINKS_RESULT_FILTER: &str = r#"if (.backlinks | length) == 0 then "No backlinks found for \"\(.file)\"" else "\(.backlinks | length) \(if (.backlinks | length) == 1 then "backlink" else "backlinks" end) for \"\(.file)\"\n\(.backlinks | map("  \(.source): line \(.line)") | join("\n"))" end"#;
365
366/// `LinksFix result`: `{applied, broken, fixable, fixes, ignored, unfixable, unfixable_links}`
367/// Format: summary line with fix status.
368const LINKS_FIX_FILTER: &str = r#""Broken links: \(.broken)\nFixable: \(.fixable)\nUnfixable: \(.unfixable)\nIgnored: \(.ignored)\nApplied: \(if .applied then "yes" else "no" end)\(if (.fixes | length) > 0 then "\n\(.fixes | map("  \(.source) line \(.line): \"\(.old_target)\" → \"\(.new_target)\"") | join("\n"))" else "" end)""#;
369
370/// `MvResult`: `{dry_run, from, to, total_files_updated, total_links_updated, updated_files}`
371/// Format: `[dry-run] Moved <from> → <to>` with list of updated files and replacements.
372const MV_RESULT_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)Moved \(.from) → \(.to)\(.updated_files | if length > 0 then "\n" + (map("  \(.file): " + (.replacements | map(.old_text + " → " + .new_text) | join(", "))) | join("\n")) else "" end)""#;
373
374/// `ViewsListEntry`: `{filters, name}`
375/// Format: `name  key=value key=value ...` — compact one-line summary of the view and its filters.
376const VIEWS_LIST_ENTRY_FILTER: &str = r#""\(.name)\t\(.filters | to_entries | map("\(.key)=\(.value | if type == "array" then join(",") else tostring end)") | join(" "))""#;
377
378/// `ViewsMutationResult`: `{action, name}`
379/// Format: `action: name`
380const VIEWS_MUTATION_RESULT_FILTER: &str = r#""\(.action): \(.name)""#;
381
382// ---------------------------------------------------------------------------
383// Shape-based filter lookup
384// ---------------------------------------------------------------------------
385
386/// Compute a sorted comma-joined key signature from a JSON object's top-level keys.
387fn key_signature(map: &serde_json::Map<String, serde_json::Value>) -> String {
388    let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
389    keys.sort_unstable();
390    keys.join(",")
391}
392
393/// Look up the jq filter for a given key signature.
394///
395/// Returns `None` for unknown shapes, which will fall back to generic formatting.
396fn lookup_filter(key_sig: &str) -> Option<&'static str> {
397    match key_sig {
398        // PropertyInfo
399        "name,type,value" => Some(PROPERTY_INFO_FILTER),
400        // PropertySummaryEntry
401        "count,name,type" => Some(PROPERTY_SUMMARY_ENTRY_FILTER),
402        // TagSummary
403        "tags,total" => Some(TAG_SUMMARY_FILTER),
404        // TagSummaryEntry
405        "count,name" => Some(TAG_SUMMARY_ENTRY_FILTER),
406        // LinkInfo variants (optional path and label → 4 combos)
407        "target" => Some(LINK_INFO_TARGET_FILTER),
408        "path,target" => Some(LINK_INFO_PATH_FILTER),
409        "label,target" => Some(LINK_INFO_LABEL_FILTER),
410        "label,path,target" => Some(LINK_INFO_FULL_FILTER),
411        // TaskCount
412        "done,total" => Some(TASK_COUNT_FILTER),
413        // OutlineSection (with and without tasks)
414        "code_blocks,heading,level,line,links" => Some(OUTLINE_SECTION_FILTER),
415        "code_blocks,heading,level,line,links,tasks" => Some(OUTLINE_SECTION_WITH_TASKS_FILTER),
416        // TaskInfo
417        "done,line,status,text" => Some(TASK_INFO_FILTER),
418        // FindTaskInfo
419        "done,line,section,status,text" => Some(FIND_TASK_INFO_FILTER),
420        // ContentMatch
421        "line,section,text" => Some(CONTENT_MATCH_FILTER),
422        // TaskReadResult
423        "done,file,line,status,text" => Some(TASK_READ_RESULT_FILTER),
424        // VaultSummary
425        "dead_ends,files,links,orphans,properties,recent_files,status,tags,tasks"
426        | "dead_ends,files,links,orphans,properties,recent_files,schema,status,tags,tasks" => {
427            Some(VAULT_SUMMARY_FILTER)
428        }
429        // Mutation results with property + value (SetPropertyResult, AppendPropertyResult,
430        // RemovePropertyResult with value)
431        "dry_run,modified,property,scanned,skipped,total,value" => {
432            Some(PROPERTY_VALUE_MUTATION_FILTER)
433        }
434        // Mutation results with property only (RemovePropertyResult without value)
435        "dry_run,modified,property,scanned,skipped,total" => Some(PROPERTY_MUTATION_FILTER),
436        // Mutation results with tag (SetTagResult, RemoveTagResult)
437        "dry_run,modified,scanned,skipped,tag,total" => Some(TAG_MUTATION_FILTER),
438        // BacklinksResult
439        "backlinks,file" => Some(BACKLINKS_RESULT_FILTER),
440        // LinksFix result
441        "applied,broken,fixable,fixes,ignored,unfixable,unfixable_links" => Some(LINKS_FIX_FILTER),
442        // MvResult
443        "dry_run,from,to,total_files_updated,total_links_updated,updated_files" => {
444            Some(MV_RESULT_FILTER)
445        }
446        // ViewsListEntry
447        "filters,name" => Some(VIEWS_LIST_ENTRY_FILTER),
448        // ViewsMutationResult
449        "action,name" => Some(VIEWS_MUTATION_RESULT_FILTER),
450        _ => None,
451    }
452}
453
454// ---------------------------------------------------------------------------
455// jq filter execution engine
456// ---------------------------------------------------------------------------
457
458/// Apply a jq filter string to a `serde_json::Value` and return the text output.
459///
460/// Looks up or compiles the filter in `cache`. Multiple outputs are joined with
461/// newlines. On any error (parse or runtime), returns `None` (used internally
462/// by the text formatter, which has its own fallbacks).
463fn apply_jq_filter(
464    filter_code: &str,
465    value: &serde_json::Value,
466    cache: &mut JaqFilterCache,
467) -> Option<String> {
468    run_jq_filter_cached(filter_code, value, cache).ok()
469}
470
471/// Apply a user-supplied jq filter to a `serde_json::Value`.
472///
473/// Compiles the filter on every call. For repeated use across many values,
474/// prefer the cached path via [`format_success`] / [`format_value_as_text`].
475///
476/// Returns `Ok(String)` with newline-joined output values on success, or
477/// `Err(String)` with a human-readable description of the parse or runtime error.
478pub fn apply_jq_filter_result(
479    filter_code: &str,
480    value: &serde_json::Value,
481) -> Result<String, String> {
482    let filter = compile_jq_filter(filter_code)?;
483    execute_jq_filter(&filter, value)
484}
485
486/// Format a jaq load error (lex/parse/IO) into a human-readable string.
487///
488/// `load::Error<&str>` does not implement `Display`, so we extract the first
489/// error's kind and the offending source snippet manually.
490fn format_load_errors(errs: &load::Errors<&str, ()>) -> String {
491    // errs is Vec<(File<&str, ()>, load::Error<&str>)>
492    // We take the first entry and describe its error kind.
493    for (_file, err) in errs {
494        match err {
495            load::Error::Io(ios) => {
496                if let Some((_path, msg)) = ios.first() {
497                    return format!("jq filter error (IO): {msg}");
498                }
499            }
500            load::Error::Lex(lex_errs) => {
501                if let Some((expect, span)) = lex_errs.first() {
502                    return format!(
503                        "jq filter syntax error: expected {} near {:?}",
504                        expect.as_str(),
505                        span
506                    );
507                }
508            }
509            load::Error::Parse(parse_errs) => {
510                if let Some((expect, _token)) = parse_errs.first() {
511                    return format!("jq filter parse error: expected {}", expect.as_str());
512                }
513            }
514        }
515    }
516    "jq filter error: invalid filter syntax".to_owned()
517}
518
519/// Compile a jq filter string into a reusable `Filter`.
520///
521/// The `Arena` used during loading is a temporary scratch pad and is dropped
522/// after this function returns — the compiled `Filter` owns all its data.
523fn compile_jq_filter(filter_code: &str) -> Result<jaq_core::compile::Filter<Native<D>>, String> {
524    let program = File {
525        code: filter_code,
526        path: (),
527    };
528    let defs = jaq_core::defs()
529        .chain(jaq_std::defs())
530        .chain(jaq_json::defs());
531    let loader = Loader::new(defs);
532    let arena = Arena::default();
533
534    let modules = loader
535        .load(&arena, program)
536        .map_err(|errs| format_load_errors(&errs))?;
537
538    let funs = jaq_core::funs::<D>()
539        .chain(jaq_std::funs::<D>())
540        .chain(jaq_json::funs::<D>());
541    Compiler::default()
542        .with_funs(funs)
543        .compile(modules)
544        .map_err(|errs| {
545            // compile::Errors = Vec<(File<S,P>, Vec<(S, Undefined)>)>
546            // Extract the first undefined symbol name for a useful message.
547            let first = errs.iter().flat_map(|(_file, undefs)| undefs.iter()).next();
548            if let Some((name, undef)) = first {
549                format!("jq filter error: undefined {} {:?}", undef.as_str(), name)
550            } else {
551                "jq filter error: compilation failed".to_owned()
552            }
553        })
554}
555
556/// Execute a pre-compiled jq filter against a JSON value and return the text output.
557fn execute_jq_filter(
558    filter: &jaq_core::compile::Filter<Native<D>>,
559    value: &serde_json::Value,
560) -> Result<String, String> {
561    let input: Val = serde_json::from_value(value.clone())
562        .map_err(|e| format!("jq input conversion error: {e}"))?;
563    let ctx = Ctx::<D>::new(&filter.lut, Vars::new([]));
564
565    let mut parts = Vec::new();
566    for result in filter.id.run((ctx, input)).map(jaq_core::unwrap_valr) {
567        match result {
568            Ok(val) => {
569                let s = match val {
570                    Val::TStr(ref s) | Val::BStr(ref s) => match std::str::from_utf8(s) {
571                        Ok(valid) => valid.to_owned(),
572                        Err(_) => String::from_utf8_lossy(s).into_owned(),
573                    },
574                    // For non-string values, `Display` produces valid JSON
575                    // (numbers, booleans, null, arrays, objects).
576                    other => other.to_string(),
577                };
578                parts.push(s);
579            }
580            Err(e) => return Err(format!("jq runtime error: {e}")),
581        }
582    }
583
584    Ok(parts.join("\n"))
585}
586
587/// Look up or compile a jq filter from `cache`, then execute it against `value`.
588fn run_jq_filter_cached(
589    filter_code: &str,
590    value: &serde_json::Value,
591    cache: &mut JaqFilterCache,
592) -> Result<String, String> {
593    if let Some(filter) = cache.get(filter_code) {
594        return execute_jq_filter(filter, value);
595    }
596    let compiled = compile_jq_filter(filter_code)?;
597    let filter = cache.entry(filter_code.to_owned()).or_insert(compiled);
598    execute_jq_filter(filter, value)
599}
600
601// ---------------------------------------------------------------------------
602// FileObject dynamic filter builder
603// ---------------------------------------------------------------------------
604
605/// Build a jaq filter string for a `FileObject` by inspecting which optional
606/// fields are present in the JSON object.
607///
608/// The file header is always emitted. Each optional section (properties, tags,
609/// sections, tasks, matches, links) is included only when the key is present.
610///
611/// **How it works:** Each part is a jaq expression that either emits a string or
612/// `empty` (when the field is absent/empty). Parts are joined with `, ` — jaq's
613/// alternation operator — so the filter produces one output per present section.
614/// `run_jq_filter` then joins those outputs with `"\n"`, producing the final
615/// multi-line text block. This coupling is intentional: changing the separator
616/// in `run_jq_filter` would affect `FileObject` rendering.
617fn build_file_object_filter(map: &serde_json::Map<String, serde_json::Value>) -> String {
618    // Header: file path and modified timestamp — always present.
619    let mut parts = vec![r#""\"\(.file)\"  (\(.modified))""#.to_owned()];
620
621    // Title: "  title: <value>" or "  title: (none)"
622    if map.contains_key("title") {
623        parts.push(r#""  title: \(if .title != null then .title else "(none)" end)""#.to_owned());
624    }
625
626    // Properties: header then each as "    key: value"
627    if map.contains_key("properties") {
628        parts.push(
629            r#"if (.properties | length) > 0 then "  properties:\n\(.properties | to_entries | map("    \(.key): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
630        );
631    }
632
633    // Properties (typed): header then each as "    name (type): value"
634    if map.contains_key("properties_typed") {
635        parts.push(
636            r#"if (.properties_typed | length) > 0 then "  properties_typed:\n\(.properties_typed | map("    \(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
637        );
638    }
639
640    // Tags: "  tags: [tag1, tag2, ...]"
641    if map.contains_key("tags") {
642        parts.push(
643            r#"if (.tags | length) > 0 then "  tags: [\(.tags | join(", "))]" else empty end"#
644                .to_owned(),
645        );
646    }
647
648    // Sections: header then each as "    ## Heading [done/total]" or "    ## Heading"
649    // Note: uses r##"..."## because the jq filter contains the sequence "#" (hash-quoted).
650    if map.contains_key("sections") {
651        parts.push(
652            r##"if (.sections | length) > 0 then "  sections:\n\(.sections | map("    \("#" * .level) \(.heading // "(pre-heading)")\(if .tasks then " [\(.tasks.done)/\(.tasks.total)]" else "" end)") | join("\n"))" else empty end"##.to_owned(),
653        );
654    }
655
656    // Tasks: header then each as "    [x] text (line N)"
657    if map.contains_key("tasks") {
658        parts.push(
659            r#"if (.tasks | length) > 0 then "  tasks:\n\(.tasks | map("    [\(if .done then "x" else " " end)] \(.text) (line \(.line))") | join("\n"))" else empty end"#.to_owned(),
660        );
661    }
662
663    // Matches: header then each as "    line N (section): text"
664    if map.contains_key("matches") {
665        parts.push(
666            r#"if (.matches | length) > 0 then "  matches:\n\(.matches | map("    line \(.line) (\(.section)): \(.text)") | join("\n"))" else empty end"#.to_owned(),
667        );
668    }
669
670    // Score: "  score: <value>" — BM25 relevance score when pattern search was used
671    if map.contains_key("score") {
672        parts.push(r#""  score: \(.score)""#.to_owned());
673    }
674
675    // Links: header then each as "    \"target\" → \"path\"" or "    \"target\" (unresolved)"
676    if map.contains_key("links") {
677        parts.push(
678            r#"if (.links | length) > 0 then "  links:\n\(.links | map("    \"\(.target)\"\(if .path then " → \"\(.path)\"" else " (unresolved)" end)") | join("\n"))" else empty end"#.to_owned(),
679        );
680    }
681
682    // Backlinks: header then each as "    \"source\" line N" or "    \"source\" line N: label"
683    if map.contains_key("backlinks") {
684        parts.push(
685            r#"if (.backlinks | length) > 0 then "  backlinks:\n\(.backlinks | map("    \"\(.source)\" line \(.line)\(if .label then ": \(.label)" else "" end)") | join("\n"))" else empty end"#.to_owned(),
686        );
687    }
688
689    parts.join(", ")
690}
691
692// ---------------------------------------------------------------------------
693// Text formatting
694// ---------------------------------------------------------------------------
695
696/// Format a JSON value as human-readable text using jq filters where available.
697fn format_value_as_text(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
698    match value {
699        serde_json::Value::Array(arr) => {
700            // TypeList: array of type list entries — use custom formatter with blank-line separation.
701            let is_type_list = arr.first().and_then(|v| v.as_object()).is_some_and(|m| {
702                key_signature(m) == "has_filename_template,property_count,required,type"
703            });
704            if is_type_list {
705                return arr
706                    .iter()
707                    .filter_map(|v| v.as_object())
708                    .map(format_type_list_entry_text)
709                    .collect::<Vec<_>>()
710                    .join("\n\n");
711            }
712            // Use blank-line separator between FileObjects for readability.
713            let is_file_objects = arr
714                .first()
715                .and_then(|v| v.as_object())
716                .is_some_and(|m| m.contains_key("file") && m.contains_key("modified"));
717            let sep = if is_file_objects { "\n\n" } else { "\n" };
718            arr.iter()
719                .map(|v| format_value_as_text(v, cache))
720                .collect::<Vec<_>>()
721                .join(sep)
722        }
723        serde_json::Value::Object(map) => {
724            let sig = key_signature(map);
725            if let Some(filter) = lookup_filter(&sig)
726                && let Some(output) = apply_jq_filter(filter, value, cache)
727            {
728                return output;
729            }
730            // TypeShow: detected by presence of "properties" object + "required" array + "type" string.
731            if sig == "defaults,filename_template,properties,required,type" {
732                return format_type_show_text(map);
733            }
734            // LintOutput: detected by "files" array of {file, violations} + "total".
735            if map.contains_key("total")
736                && map.contains_key("files")
737                && let Some(serde_json::Value::Array(arr)) = map.get("files")
738            {
739                let is_lint = arr
740                    .first()
741                    .and_then(|v| v.as_object())
742                    .is_some_and(|m| m.contains_key("file") && m.contains_key("violations"))
743                    || arr.is_empty();
744                if is_lint {
745                    return format_lint_output_text(map);
746                }
747            }
748            // FileObject: dynamically compose filter from present fields.
749            if map.contains_key("file") && map.contains_key("modified") {
750                let filter = build_file_object_filter(map);
751                if let Some(output) = apply_jq_filter(&filter, value, cache) {
752                    return output;
753                }
754            }
755            // Fallback: generic key: value lines
756            format_object_generic(map, cache)
757        }
758        other => format_scalar(other, cache),
759    }
760}
761
762/// Format `LintOutput` JSON as human-readable text.
763///
764/// Reproduces the format previously generated by `commands::lint::format_text_output`.
765fn format_lint_output_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
766    use std::fmt::Write as _;
767
768    let mut s = String::new();
769    let dry_run = map
770        .get("dry_run")
771        .and_then(serde_json::Value::as_bool)
772        .unwrap_or(false);
773
774    // Fix actions (shown first).
775    if let Some(fixes_arr) = map.get("fixes").and_then(|f| f.as_array()) {
776        let verb = if dry_run { "Would fix" } else { "Fixed" };
777        for file_fix in fixes_arr {
778            let file = file_fix
779                .get("file")
780                .and_then(serde_json::Value::as_str)
781                .unwrap_or("?");
782            let actions = file_fix.get("actions").and_then(|a| a.as_array());
783            let Some(actions) = actions else { continue };
784            if actions.is_empty() {
785                continue;
786            }
787            let _ = writeln!(s, "{verb} {file}:");
788            for a in actions {
789                let kind = a
790                    .get("kind")
791                    .and_then(serde_json::Value::as_str)
792                    .unwrap_or("");
793                let property = a
794                    .get("property")
795                    .and_then(serde_json::Value::as_str)
796                    .unwrap_or("?");
797                let new = a
798                    .get("new")
799                    .and_then(serde_json::Value::as_str)
800                    .unwrap_or("");
801                let old = a.get("old").and_then(serde_json::Value::as_str);
802                match (kind, old) {
803                    ("insert-default", _) => {
804                        let _ = writeln!(s, "  insert  {property} = {new:?}");
805                    }
806                    ("infer-type", _) => {
807                        let _ = writeln!(s, "  infer   type = {new:?}");
808                    }
809                    ("fix-enum-typo", Some(old_v)) => {
810                        let _ = writeln!(s, "  enum    {property}: {old_v:?} -> {new:?}");
811                    }
812                    ("normalize-date", Some(old_v)) => {
813                        let _ = writeln!(s, "  date    {property}: {old_v:?} -> {new:?}");
814                    }
815                    _ => {
816                        let _ = writeln!(s, "  {kind}  {property} = {new:?}");
817                    }
818                }
819            }
820        }
821    }
822
823    // File violations.
824    let files = map.get("files").and_then(|f| f.as_array());
825    let mut error_count: usize = 0;
826    let mut warn_count: usize = 0;
827    let mut files_with_issues: usize = 0;
828    if let Some(files) = files {
829        for file_entry in files {
830            let file = file_entry
831                .get("file")
832                .and_then(serde_json::Value::as_str)
833                .unwrap_or("?");
834            let violations = file_entry.get("violations").and_then(|v| v.as_array());
835            let Some(violations) = violations else {
836                continue;
837            };
838            if violations.is_empty() {
839                continue;
840            }
841            files_with_issues += 1;
842            let _ = writeln!(s, "{file}:");
843            for v in violations {
844                let severity = v
845                    .get("severity")
846                    .and_then(serde_json::Value::as_str)
847                    .unwrap_or("warn");
848                let message = v
849                    .get("message")
850                    .and_then(serde_json::Value::as_str)
851                    .unwrap_or("");
852                let pad = if severity == "error" {
853                    error_count += 1;
854                    "error"
855                } else {
856                    warn_count += 1;
857                    "warn "
858                };
859                let _ = writeln!(s, "  {pad}  {message}");
860            }
861        }
862    }
863
864    // Summary line.
865    let files_checked: u64 = map
866        .get("files_checked")
867        .and_then(serde_json::Value::as_u64)
868        .unwrap_or(0);
869    let files_label = if files_checked == 1 { "file" } else { "files" };
870    if error_count == 0 && warn_count == 0 {
871        let _ = write!(s, "{files_checked} {files_label} checked, no issues");
872    } else {
873        let _ = write!(
874            s,
875            "{files_checked} {files_label} checked, {files_with_issues} with issues ({error_count} errors, {warn_count} warnings)",
876        );
877    }
878
879    let fix_count: usize = map
880        .get("fixes")
881        .and_then(|f| f.as_array())
882        .map_or(0, |arr| {
883            arr.iter()
884                .filter_map(|f| f.get("actions").and_then(|a| a.as_array()).map(Vec::len))
885                .sum()
886        });
887    if fix_count > 0 {
888        let fixed_label = if dry_run { "would fix" } else { "fixed" };
889        let _ = write!(s, " — {fixed_label} {fix_count}");
890    }
891
892    s
893}
894
895/// Format a `types show` result as human-readable text.
896///
897/// Expected JSON shape: `{type, required, filename_template, defaults, properties}`.
898/// Output example:
899/// ```text
900/// Type: iteration
901///
902/// Required: title, type, date
903///
904/// Properties:
905///   branch:
906///     type: string
907///     pattern: ^iter-\d+/
908///
909///   date:
910///     type: date
911///
912/// Filename template: iteration-{N}-{slug}.md
913/// ```
914fn format_type_show_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
915    use std::fmt::Write as _;
916
917    let mut s = String::new();
918
919    let type_name = map
920        .get("type")
921        .and_then(serde_json::Value::as_str)
922        .unwrap_or("?");
923    let _ = write!(s, "Type: {type_name}");
924
925    // Required fields.
926    if let Some(serde_json::Value::Array(req)) = map.get("required")
927        && !req.is_empty()
928    {
929        let list: Vec<&str> = req.iter().filter_map(serde_json::Value::as_str).collect();
930        let _ = write!(s, "\n\nRequired: {}", list.join(", "));
931    }
932
933    // Defaults block.
934    if let Some(serde_json::Value::Object(defaults)) = map.get("defaults")
935        && !defaults.is_empty()
936    {
937        let _ = write!(s, "\n\nDefaults:");
938        let mut keys: Vec<&str> = defaults.keys().map(String::as_str).collect();
939        keys.sort_unstable();
940        for key in keys {
941            if let Some(value) = defaults.get(key) {
942                let display = match value {
943                    serde_json::Value::String(sv) => sv.clone(),
944                    other => other.to_string(),
945                };
946                let _ = write!(s, "\n  {key}: {display}");
947            }
948        }
949    }
950
951    // Properties block.
952    if let Some(serde_json::Value::Object(props)) = map.get("properties")
953        && !props.is_empty()
954    {
955        let _ = write!(s, "\n\nProperties:");
956        let mut prop_names: Vec<&str> = props.keys().map(String::as_str).collect();
957        prop_names.sort_unstable();
958        for name in prop_names {
959            let Some(prop_val) = props.get(name) else {
960                continue;
961            };
962            let _ = write!(s, "\n  {name}:");
963            if let Some(obj) = prop_val.as_object() {
964                // Print each constraint key on its own indented line.
965                // Always show "type" first, then remaining keys sorted.
966                let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
967                keys.sort_unstable_by(|a, b| {
968                    if *a == "type" {
969                        std::cmp::Ordering::Less
970                    } else if *b == "type" {
971                        std::cmp::Ordering::Greater
972                    } else {
973                        a.cmp(b)
974                    }
975                });
976                for key in keys {
977                    if let Some(v) = obj.get(key) {
978                        let display = match v {
979                            serde_json::Value::Array(arr) => arr
980                                .iter()
981                                .filter_map(serde_json::Value::as_str)
982                                .collect::<Vec<_>>()
983                                .join(", "),
984                            serde_json::Value::String(sv) => sv.clone(),
985                            other => other.to_string(),
986                        };
987                        let _ = write!(s, "\n    {key}: {display}");
988                    }
989                }
990            }
991            s.push('\n'); // blank line between property blocks
992        }
993    }
994
995    // Optional filename template.
996    if let Some(serde_json::Value::String(tmpl)) = map.get("filename_template") {
997        let _ = write!(s, "\nFilename template: {tmpl}");
998    }
999
1000    s
1001}
1002
1003/// Format a single `types list` entry as human-readable text.
1004///
1005/// Expected JSON shape: `{type, required, property_count, has_filename_template}`.
1006/// Output example:
1007/// ```text
1008/// iteration (4 required, 6 properties)
1009///   required: title, type, date, tags
1010/// ```
1011///
1012/// Note: `has_filename_template` is a boolean; the actual template is only in `types show`.
1013/// When present, a hint to run `types show` is appended.
1014fn format_type_list_entry_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
1015    use std::fmt::Write as _;
1016
1017    let mut s = String::new();
1018
1019    let type_name = map
1020        .get("type")
1021        .and_then(serde_json::Value::as_str)
1022        .unwrap_or("?");
1023
1024    let req_arr: &[serde_json::Value] = map
1025        .get("required")
1026        .and_then(serde_json::Value::as_array)
1027        .map_or(&[], Vec::as_slice);
1028    let req_count = req_arr.len();
1029
1030    let prop_count = map
1031        .get("property_count")
1032        .and_then(serde_json::Value::as_u64)
1033        .unwrap_or(0);
1034
1035    let has_filename = map
1036        .get("has_filename_template")
1037        .and_then(serde_json::Value::as_bool)
1038        .unwrap_or(false);
1039
1040    let prop_label = if prop_count == 1 {
1041        "property"
1042    } else {
1043        "properties"
1044    };
1045    let _ = write!(
1046        s,
1047        "{type_name} ({req_count} required, {prop_count} {prop_label})"
1048    );
1049
1050    if !req_arr.is_empty() {
1051        let list: Vec<&str> = req_arr
1052            .iter()
1053            .filter_map(serde_json::Value::as_str)
1054            .collect();
1055        let _ = write!(s, "\n  required: {}", list.join(", "));
1056    }
1057
1058    if has_filename {
1059        let _ = write!(s, "\n  filename: (see type details)");
1060    }
1061
1062    s
1063}
1064
1065/// Generic key: value rendering for unknown object shapes.
1066fn format_object_generic(
1067    map: &serde_json::Map<String, serde_json::Value>,
1068    cache: &mut JaqFilterCache,
1069) -> String {
1070    map.iter()
1071        .map(|(k, v)| format!("{k}: {}", format_value_as_text(v, cache)))
1072        .collect::<Vec<_>>()
1073        .join("\n")
1074}
1075
1076/// Format a scalar JSON value as text.
1077fn format_scalar(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
1078    match value {
1079        serde_json::Value::String(s) => s.clone(),
1080        serde_json::Value::Number(n) => n.to_string(),
1081        serde_json::Value::Bool(b) => b.to_string(),
1082        serde_json::Value::Null => "null".to_owned(),
1083        serde_json::Value::Array(arr) => {
1084            let items: Vec<String> = arr.iter().map(|v| format_scalar(v, cache)).collect();
1085            items.join(", ")
1086        }
1087        serde_json::Value::Object(_) => format_value_as_text(value, cache),
1088    }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094    use serde_json::json;
1095
1096    // Convenience wrappers so individual tests don't have to construct a cache.
1097    fn jq(filter: &str, val: &serde_json::Value) -> Option<String> {
1098        apply_jq_filter(filter, val, &mut JaqFilterCache::new())
1099    }
1100
1101    fn fmt(val: &serde_json::Value) -> String {
1102        format_value_as_text(val, &mut JaqFilterCache::new())
1103    }
1104
1105    fn scalar(val: &serde_json::Value) -> String {
1106        format_scalar(val, &mut JaqFilterCache::new())
1107    }
1108
1109    // --- error formatting ---
1110
1111    #[test]
1112    fn format_json_error() {
1113        let out = format_error(
1114            Format::Json,
1115            "file not found",
1116            Some("foo/bar"),
1117            Some("did you mean foo/bar.md?"),
1118            None,
1119        );
1120        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1121        assert_eq!(parsed["error"], "file not found");
1122        assert_eq!(parsed["hint"], "did you mean foo/bar.md?");
1123        assert!(parsed.get("cause").is_none());
1124    }
1125
1126    #[test]
1127    fn format_text_error() {
1128        let out = format_error(Format::Text, "file not found", Some("foo"), None, None);
1129        assert!(out.contains("Error: file not found"));
1130        assert!(out.contains("path: foo"));
1131    }
1132
1133    #[test]
1134    fn format_json_success() {
1135        let val = json!({"name": "test", "value": 42});
1136        let out = format_success(Format::Json, &val);
1137        assert!(out.contains("\"name\": \"test\""));
1138    }
1139
1140    // --- apply_jq_filter ---
1141
1142    #[test]
1143    fn apply_jq_filter_simple() {
1144        let val = json!({"name": "hello", "count": 3});
1145        let result = jq(r#""\(.name): \(.count)""#, &val);
1146        assert_eq!(result.as_deref(), Some("hello: 3"));
1147    }
1148
1149    #[test]
1150    fn apply_jq_filter_array_map() {
1151        let val = json!(["a", "b", "c"]);
1152        let result = jq(".[]", &val);
1153        assert_eq!(result.as_deref(), Some("a\nb\nc"));
1154    }
1155
1156    #[test]
1157    fn apply_jq_filter_invalid_returns_none() {
1158        let val = json!({"x": 1});
1159        let result = jq("this is not valid jq %%%", &val);
1160        assert!(result.is_none());
1161    }
1162
1163    // --- property type filters ---
1164
1165    #[test]
1166    fn property_info_filter() {
1167        let val = json!({"name": "title", "type": "text", "value": "My Note"});
1168        let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
1169        assert!(out.contains("title"));
1170        assert!(out.contains("text"));
1171        assert!(out.contains("My Note"));
1172    }
1173
1174    #[test]
1175    fn property_info_filter_list_value() {
1176        let val = json!({"name": "tags", "type": "list", "value": ["rust", "cli"]});
1177        let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
1178        assert!(out.contains("tags"));
1179        assert!(out.contains("list"));
1180        // Array values should be wrapped in brackets and joined with ", "
1181        assert!(out.contains("[rust, cli]"), "expected [rust, cli]: {out}");
1182        assert!(!out.contains("[\"rust\""));
1183    }
1184
1185    #[test]
1186    fn property_summary_entry_filter() {
1187        let val = json!({"count": 7, "name": "title", "type": "text"});
1188        let out = jq(PROPERTY_SUMMARY_ENTRY_FILTER, &val).unwrap();
1189        assert!(out.contains("title"));
1190        assert!(out.contains("text"));
1191        assert!(out.contains("7 files"));
1192    }
1193
1194    #[test]
1195    fn tag_summary_filter() {
1196        let val = json!({
1197            "tags": [{"name": "rust", "count": 3}, {"name": "cli", "count": 1}],
1198            "total": 2
1199        });
1200        let out = jq(TAG_SUMMARY_FILTER, &val).unwrap();
1201        assert!(out.contains("2 unique tags"));
1202        assert!(out.contains("rust"));
1203        assert!(out.contains("3 files"));
1204    }
1205
1206    // --- link type filters ---
1207
1208    #[test]
1209    fn link_info_target_only_filter() {
1210        let val = json!({"target": "broken-link"});
1211        let out = jq(LINK_INFO_TARGET_FILTER, &val).unwrap();
1212        assert!(out.contains("broken-link"));
1213        assert!(out.contains("unresolved"));
1214    }
1215
1216    #[test]
1217    fn link_info_with_path_filter() {
1218        let val = json!({"path": "note-b.md", "target": "note-b"});
1219        let out = jq(LINK_INFO_PATH_FILTER, &val).unwrap();
1220        assert!(out.contains("note-b"));
1221        assert!(out.contains("note-b.md"));
1222    }
1223
1224    // --- outline type filters ---
1225
1226    #[test]
1227    fn task_count_filter() {
1228        let val = json!({"done": 3, "total": 5});
1229        let out = jq(TASK_COUNT_FILTER, &val).unwrap();
1230        assert_eq!(out, "[3/5]");
1231    }
1232
1233    #[test]
1234    fn outline_section_filter() {
1235        let val = json!({
1236            "code_blocks": [],
1237            "heading": "Introduction",
1238            "level": 1,
1239            "line": 5,
1240            "links": ["[[other]]"]
1241        });
1242        let out = jq(OUTLINE_SECTION_FILTER, &val).unwrap();
1243        assert!(out.contains('#'));
1244        assert!(out.contains("Introduction"));
1245        assert!(out.contains("[[other]]"));
1246    }
1247
1248    #[test]
1249    fn outline_section_with_tasks_filter() {
1250        let val = json!({
1251            "code_blocks": [],
1252            "heading": "Tasks",
1253            "level": 2,
1254            "line": 10,
1255            "links": [],
1256            "tasks": {"done": 2, "total": 4}
1257        });
1258        let out = jq(OUTLINE_SECTION_WITH_TASKS_FILTER, &val).unwrap();
1259        assert!(out.contains("##"));
1260        assert!(out.contains("Tasks"));
1261        assert!(out.contains("[2/4]"));
1262    }
1263
1264    // --- FindTaskInfo filter ---
1265
1266    #[test]
1267    fn find_task_info_filter_done() {
1268        let val = json!({
1269            "done": true,
1270            "line": 42,
1271            "section": "Implementation",
1272            "status": "x",
1273            "text": "Write the tests"
1274        });
1275        let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
1276        assert!(out.contains("[x]"));
1277        assert!(out.contains("Write the tests"));
1278        assert!(out.contains("line 42"));
1279        assert!(out.contains("Implementation"));
1280    }
1281
1282    #[test]
1283    fn find_task_info_filter_not_done() {
1284        let val = json!({
1285            "done": false,
1286            "line": 7,
1287            "section": "Todo",
1288            "status": " ",
1289            "text": "Review PR"
1290        });
1291        let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
1292        assert!(out.contains("[ ]"));
1293        assert!(out.contains("Review PR"));
1294        assert!(out.contains("line 7"));
1295        assert!(out.contains("Todo"));
1296    }
1297
1298    #[test]
1299    fn find_task_info_via_format_value_as_text() {
1300        // Verify that format_value_as_text dispatches to the correct filter.
1301        let val = json!({
1302            "done": true,
1303            "line": 5,
1304            "section": "Goals",
1305            "status": "x",
1306            "text": "Ship it"
1307        });
1308        let out = fmt(&val);
1309        assert!(out.contains("[x]"));
1310        assert!(out.contains("Ship it"));
1311        assert!(
1312            !out.contains("done: true"),
1313            "should not use generic fallback"
1314        );
1315    }
1316
1317    // --- ContentMatch filter ---
1318
1319    #[test]
1320    fn content_match_filter() {
1321        let val = json!({
1322            "line": 15,
1323            "section": "Background",
1324            "text": "This is the matching line"
1325        });
1326        let out = jq(CONTENT_MATCH_FILTER, &val).unwrap();
1327        assert!(out.contains("line 15"));
1328        assert!(out.contains("Background"));
1329        assert!(out.contains("This is the matching line"));
1330    }
1331
1332    #[test]
1333    fn content_match_via_format_value_as_text() {
1334        let val = json!({
1335            "line": 3,
1336            "section": "Intro",
1337            "text": "hello world"
1338        });
1339        let out = fmt(&val);
1340        assert!(out.contains("line 3"));
1341        assert!(out.contains("hello world"));
1342        assert!(!out.contains("line: 3"), "should not use generic fallback");
1343    }
1344
1345    // --- Mutation result filters ---
1346
1347    #[test]
1348    fn property_value_mutation_filter_with_modified() {
1349        // SetPropertyResult / AppendPropertyResult / RemovePropertyResult (with value)
1350        // scanned == total: no "(N scanned)" suffix
1351        let val = json!({
1352            "modified": ["note-a.md", "note-b.md"],
1353            "property": "status",
1354            "scanned": 2,
1355            "skipped": [],
1356            "total": 2,
1357            "value": "done"
1358        });
1359        let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1360        assert!(out.contains("status=done"));
1361        assert!(out.contains("2/2 modified"));
1362        assert!(
1363            !out.contains("scanned"),
1364            "no scanned suffix when scanned == total"
1365        );
1366        assert!(out.contains("note-a.md"));
1367        assert!(out.contains("note-b.md"));
1368    }
1369
1370    #[test]
1371    fn property_value_mutation_filter_all_skipped() {
1372        let val = json!({
1373            "modified": [],
1374            "property": "priority",
1375            "scanned": 1,
1376            "skipped": ["note-a.md"],
1377            "total": 1,
1378            "value": "high"
1379        });
1380        let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1381        assert!(out.contains("priority=high"));
1382        assert!(out.contains("0/1 modified"));
1383        // No file paths should appear when nothing was modified
1384        assert!(!out.contains("note-a.md"));
1385    }
1386
1387    #[test]
1388    fn property_value_mutation_filter_with_where_filter() {
1389        // scanned > total: "(N scanned)" suffix should appear
1390        let val = json!({
1391            "modified": ["note-a.md"],
1392            "property": "status",
1393            "scanned": 5,
1394            "skipped": [],
1395            "total": 1,
1396            "value": "done"
1397        });
1398        let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1399        assert!(out.contains("status=done"));
1400        assert!(out.contains("1/1 modified"));
1401        assert!(out.contains("(5 scanned)"));
1402    }
1403
1404    #[test]
1405    fn property_value_mutation_via_format_value_as_text() {
1406        let val = json!({
1407            "dry_run": false,
1408            "modified": ["notes/a.md"],
1409            "property": "status",
1410            "scanned": 1,
1411            "skipped": [],
1412            "total": 1,
1413            "value": "done"
1414        });
1415        let out = fmt(&val);
1416        assert!(out.contains("status=done"));
1417        assert!(
1418            !out.contains("modified: "),
1419            "should not use generic fallback"
1420        );
1421    }
1422
1423    #[test]
1424    fn property_mutation_filter_no_value() {
1425        // RemovePropertyResult without value; scanned == total
1426        let val = json!({
1427            "dry_run": false,
1428            "modified": ["note.md"],
1429            "property": "draft",
1430            "scanned": 1,
1431            "skipped": [],
1432            "total": 1
1433        });
1434        let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1435        assert!(out.contains("draft"));
1436        assert!(out.contains("1/1 modified"));
1437        assert!(
1438            !out.contains("scanned"),
1439            "no scanned suffix when scanned == total"
1440        );
1441        assert!(out.contains("note.md"));
1442    }
1443
1444    #[test]
1445    fn property_mutation_filter_no_value_with_where_filter() {
1446        // RemovePropertyResult without value; scanned > total
1447        let val = json!({
1448            "dry_run": false,
1449            "modified": ["note.md"],
1450            "property": "draft",
1451            "scanned": 7,
1452            "skipped": [],
1453            "total": 1
1454        });
1455        let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1456        assert!(out.contains("draft"));
1457        assert!(out.contains("1/1 modified"));
1458        assert!(out.contains("(7 scanned)"));
1459    }
1460
1461    #[test]
1462    fn tag_mutation_filter_with_modified() {
1463        // SetTagResult / RemoveTagResult; scanned == total
1464        let val = json!({
1465            "dry_run": false,
1466            "modified": ["a.md", "b.md"],
1467            "scanned": 3,
1468            "skipped": ["c.md"],
1469            "tag": "rust",
1470            "total": 3
1471        });
1472        let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1473        assert!(out.contains("rust"));
1474        assert!(out.contains("2/3 modified"));
1475        assert!(
1476            !out.contains("scanned"),
1477            "no scanned suffix when scanned == total"
1478        );
1479        assert!(out.contains("a.md"));
1480        assert!(out.contains("b.md"));
1481        assert!(!out.contains("c.md"));
1482    }
1483
1484    #[test]
1485    fn tag_mutation_filter_with_where_filter() {
1486        // scanned > total: "(N scanned)" suffix
1487        let val = json!({
1488            "dry_run": false,
1489            "modified": ["a.md"],
1490            "scanned": 10,
1491            "skipped": [],
1492            "tag": "rust",
1493            "total": 1
1494        });
1495        let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1496        assert!(out.contains("rust"));
1497        assert!(out.contains("1/1 modified"));
1498        assert!(out.contains("(10 scanned)"));
1499    }
1500
1501    #[test]
1502    fn tag_mutation_via_format_value_as_text() {
1503        let val = json!({
1504            "dry_run": false,
1505            "modified": [],
1506            "scanned": 1,
1507            "skipped": ["note.md"],
1508            "tag": "cli",
1509            "total": 1
1510        });
1511        let out = fmt(&val);
1512        assert!(out.contains("cli"));
1513        assert!(!out.contains("tag: cli"), "should not use generic fallback");
1514    }
1515
1516    // --- dry-run prefix in text output ---
1517
1518    #[test]
1519    fn property_value_mutation_dry_run_prefix() {
1520        let val = json!({
1521            "dry_run": true,
1522            "modified": ["note.md"],
1523            "property": "status",
1524            "scanned": 1,
1525            "skipped": [],
1526            "total": 1,
1527            "value": "done"
1528        });
1529        let out = fmt(&val);
1530        assert!(
1531            out.contains("[dry-run] status=done"),
1532            "dry-run prefix missing: {out}"
1533        );
1534    }
1535
1536    #[test]
1537    fn tag_mutation_dry_run_prefix() {
1538        let val = json!({
1539            "dry_run": true,
1540            "modified": ["note.md"],
1541            "scanned": 1,
1542            "skipped": [],
1543            "tag": "rust",
1544            "total": 1
1545        });
1546        let out = fmt(&val);
1547        assert!(
1548            out.contains("[dry-run] rust"),
1549            "dry-run prefix missing: {out}"
1550        );
1551    }
1552
1553    #[test]
1554    fn property_value_mutation_no_dry_run_prefix() {
1555        let val = json!({
1556            "dry_run": false,
1557            "modified": ["note.md"],
1558            "property": "status",
1559            "scanned": 1,
1560            "skipped": [],
1561            "total": 1,
1562            "value": "done"
1563        });
1564        let out = fmt(&val);
1565        assert!(
1566            !out.contains("[dry-run]"),
1567            "should not have dry-run prefix: {out}"
1568        );
1569    }
1570
1571    // --- build_file_object_filter ---
1572
1573    #[test]
1574    fn build_file_object_filter_minimal() {
1575        // Only the required `file` and `modified` fields.
1576        let map: serde_json::Map<String, serde_json::Value> =
1577            serde_json::from_str(r#"{"file": "notes/foo.md", "modified": "2024-01-01"}"#).unwrap();
1578        let filter = build_file_object_filter(&map);
1579        let val = json!({"file": "notes/foo.md", "modified": "2024-01-01"});
1580        let out = jq(&filter, &val).unwrap();
1581        assert!(out.contains("notes/foo.md"));
1582        assert!(out.contains("2024-01-01"));
1583    }
1584
1585    #[test]
1586    fn build_file_object_filter_with_tags() {
1587        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1588            r#"{"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]}"#,
1589        )
1590        .unwrap();
1591        let filter = build_file_object_filter(&map);
1592        let val = json!({"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]});
1593        let out = jq(&filter, &val).unwrap();
1594        assert!(out.contains("foo.md"));
1595        assert!(out.contains("tags: [rust, cli]"));
1596    }
1597
1598    #[test]
1599    fn build_file_object_filter_with_properties() {
1600        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1601            r#"{"file": "foo.md", "modified": "2024-01-01", "properties": {"status": "done"}}"#,
1602        )
1603        .unwrap();
1604        let filter = build_file_object_filter(&map);
1605        let val = json!({
1606            "file": "foo.md",
1607            "modified": "2024-01-01",
1608            "properties": {"status": "done"}
1609        });
1610        let out = jq(&filter, &val).unwrap();
1611        assert!(out.contains("foo.md"));
1612        assert!(out.contains("properties:"));
1613        assert!(out.contains("status: done"));
1614    }
1615
1616    #[test]
1617    fn build_file_object_filter_with_tasks() {
1618        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1619            r#"{"file": "foo.md", "modified": "2024-01-01", "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]}"#,
1620        )
1621        .unwrap();
1622        let filter = build_file_object_filter(&map);
1623        let val = json!({
1624            "file": "foo.md",
1625            "modified": "2024-01-01",
1626            "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]
1627        });
1628        let out = jq(&filter, &val).unwrap();
1629        assert!(out.contains("foo.md"));
1630        assert!(out.contains("tasks:"));
1631        assert!(out.contains("[x] Ship it"));
1632        assert!(out.contains("line 5"));
1633    }
1634
1635    #[test]
1636    fn build_file_object_filter_with_sections() {
1637        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1638            r#"{"file": "foo.md", "modified": "2024-01-01", "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]}"#,
1639        )
1640        .unwrap();
1641        let filter = build_file_object_filter(&map);
1642        let val = json!({
1643            "file": "foo.md",
1644            "modified": "2024-01-01",
1645            "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]
1646        });
1647        let out = jq(&filter, &val).unwrap();
1648        assert!(out.contains("foo.md"));
1649        assert!(out.contains("sections:"));
1650        assert!(out.contains("# Intro"));
1651    }
1652
1653    #[test]
1654    fn build_file_object_filter_with_matches() {
1655        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1656            r#"{"file": "foo.md", "modified": "2024-01-01", "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]}"#,
1657        )
1658        .unwrap();
1659        let filter = build_file_object_filter(&map);
1660        let val = json!({
1661            "file": "foo.md",
1662            "modified": "2024-01-01",
1663            "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]
1664        });
1665        let out = jq(&filter, &val).unwrap();
1666        assert!(out.contains("foo.md"));
1667        assert!(out.contains("matches:"));
1668        assert!(out.contains("line 3 (Intro): hello world"));
1669    }
1670
1671    #[test]
1672    fn build_file_object_filter_with_links() {
1673        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1674            r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "bar", "path": "bar.md"}]}"#,
1675        )
1676        .unwrap();
1677        let filter = build_file_object_filter(&map);
1678        let val = json!({
1679            "file": "foo.md",
1680            "modified": "2024-01-01",
1681            "links": [{"target": "bar", "path": "bar.md"}]
1682        });
1683        let out = jq(&filter, &val).unwrap();
1684        assert!(out.contains("foo.md"));
1685        assert!(out.contains("links:"));
1686        assert!(out.contains(r#""bar" → "bar.md""#));
1687    }
1688
1689    #[test]
1690    fn build_file_object_filter_unresolved_link() {
1691        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1692            r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "missing"}]}"#,
1693        )
1694        .unwrap();
1695        let filter = build_file_object_filter(&map);
1696        let val = json!({
1697            "file": "foo.md",
1698            "modified": "2024-01-01",
1699            "links": [{"target": "missing"}]
1700        });
1701        let out = jq(&filter, &val).unwrap();
1702        assert!(out.contains(r#""missing" (unresolved)"#));
1703    }
1704
1705    // --- FileObject text rendering through format_value_as_text ---
1706
1707    #[test]
1708    fn file_object_text_rendering_minimal() {
1709        let val = json!({"file": "notes/foo.md", "modified": "2024-01-15"});
1710        let out = fmt(&val);
1711        assert!(out.contains("notes/foo.md"));
1712        assert!(out.contains("2024-01-15"));
1713        // Should not look like generic fallback
1714        assert!(!out.contains("file: notes/foo.md"));
1715    }
1716
1717    #[test]
1718    fn file_object_text_rendering_full() {
1719        let val = json!({
1720            "file": "notes/project.md",
1721            "modified": "2024-03-01",
1722            "tags": ["rust", "work"],
1723            "properties": {"status": "active"},
1724            "tasks": [
1725                {"done": false, "line": 10, "section": "Todo", "status": " ", "text": "Fix bug"},
1726                {"done": true, "line": 20, "section": "Done", "status": "x", "text": "Write docs"}
1727            ]
1728        });
1729        let out = fmt(&val);
1730        assert!(out.contains("notes/project.md"));
1731        assert!(out.contains("properties:"));
1732        assert!(out.contains("status: active"));
1733        assert!(out.contains("tags: [rust, work]"));
1734        assert!(out.contains("tasks:"));
1735        assert!(out.contains("[ ] Fix bug"));
1736        assert!(out.contains("[x] Write docs"));
1737    }
1738
1739    // --- Array of FileObjects with blank-line separator ---
1740
1741    #[test]
1742    fn array_of_file_objects_uses_blank_line_separator() {
1743        let val = json!([
1744            {"file": "a.md", "modified": "2024-01-01"},
1745            {"file": "b.md", "modified": "2024-01-02"}
1746        ]);
1747        let out = fmt(&val);
1748        assert!(out.contains("a.md"));
1749        assert!(out.contains("b.md"));
1750        // Should have a blank line between entries
1751        assert!(
1752            out.contains("\n\n"),
1753            "expected blank-line separator between file objects"
1754        );
1755    }
1756
1757    #[test]
1758    fn array_of_non_file_objects_uses_single_newline() {
1759        let val = json!([
1760            {"count": 1, "name": "status", "type": "text"},
1761            {"count": 3, "name": "title", "type": "text"}
1762        ]);
1763        let out = fmt(&val);
1764        assert!(out.contains("status"));
1765        assert!(out.contains("title"));
1766        // Should NOT have a blank line separator
1767        assert!(
1768            !out.contains("\n\n"),
1769            "non-file-objects should use single newline"
1770        );
1771    }
1772
1773    // --- format_scalar nested object delegation ---
1774
1775    #[test]
1776    fn format_scalar_delegates_nested_objects() {
1777        // A nested object with a known shape should get its filter applied,
1778        // not the k=v flat format.
1779        let inner = json!({"count": 2, "name": "status", "type": "text"});
1780        let out = scalar(&inner);
1781        // Should NOT look like the old "count=2, name=status, type=text" format.
1782        assert!(
1783            !out.contains("count=2"),
1784            "should delegate to format_value_as_text"
1785        );
1786        // Should look like the PropertySummaryEntry filter output.
1787        assert!(out.contains("status"));
1788        assert!(out.contains("2 files"));
1789    }
1790
1791    // --- format_value_as_text integration ---
1792
1793    #[test]
1794    fn format_value_as_text_uses_filter_for_known_shape() {
1795        // PropertySummaryEntry has a known shape: {count, name, type}
1796        let val = json!({"count": 3, "name": "status", "type": "text"});
1797        let out = fmt(&val);
1798        assert!(out.contains("status"));
1799        assert!(out.contains("3 files"));
1800        // Should NOT look like "count: 3" (that's the generic fallback)
1801        assert!(!out.contains("count: 3"));
1802    }
1803
1804    #[test]
1805    fn format_value_as_text_falls_back_for_unknown_shape() {
1806        let val = json!({"foo": "bar", "baz": 42});
1807        let out = fmt(&val);
1808        // Generic fallback: key: value
1809        assert!(out.contains("foo: bar") || out.contains("baz: 42"));
1810    }
1811
1812    #[test]
1813    fn mv_result_filter_applied() {
1814        let val = json!({
1815            "dry_run": false,
1816            "from": "sub/b.md",
1817            "to": "archive/b.md",
1818            "total_files_updated": 1,
1819            "total_links_updated": 1,
1820            "updated_files": [
1821                {
1822                    "file": "a.md",
1823                    "replacements": [
1824                        {"old_text": "[[sub/b]]", "new_text": "[[archive/b]]", "line": 1}
1825                    ]
1826                }
1827            ]
1828        });
1829        // Verify key signature matches expected
1830        let sig = {
1831            let map = val.as_object().unwrap();
1832            let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
1833            keys.sort_unstable();
1834            keys.join(",")
1835        };
1836        assert_eq!(
1837            sig,
1838            "dry_run,from,to,total_files_updated,total_links_updated,updated_files"
1839        );
1840        // Verify the jq filter itself works
1841        let filter_result = apply_jq_filter_result(MV_RESULT_FILTER, &val);
1842        assert!(filter_result.is_ok(), "filter error: {filter_result:?}");
1843        let out = filter_result.unwrap();
1844        assert!(out.contains("Moved sub/b.md"), "out: {out}");
1845        assert!(out.contains("archive/b.md"), "out: {out}");
1846        assert!(out.contains("[[sub/b]]"), "out: {out}");
1847        assert!(out.contains("[[archive/b]]"), "out: {out}");
1848        // Verify lookup_filter finds the filter for this shape
1849        let found_filter =
1850            lookup_filter("dry_run,from,to,total_files_updated,total_links_updated,updated_files");
1851        assert!(
1852            found_filter.is_some(),
1853            "lookup_filter returned None for MvResult shape"
1854        );
1855        // format_value_as_text should pick up the filter
1856        let formatted = fmt(&val);
1857        assert!(
1858            formatted.contains("Moved sub/b.md"),
1859            "formatted: {formatted}"
1860        );
1861    }
1862
1863    #[test]
1864    fn format_value_as_text_array_of_typed_objects() {
1865        let val = json!([
1866            {"path": "a.md", "tags": ["rust"]},
1867            {"path": "b.md", "tags": ["cli"]}
1868        ]);
1869        let out = fmt(&val);
1870        assert!(out.contains("a.md"));
1871        assert!(out.contains("b.md"));
1872        assert!(out.contains("rust"));
1873        assert!(out.contains("cli"));
1874    }
1875}