Skip to main content

hyalo_cli/
output.rs

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