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