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