Skip to main content

hyalo_cli/
output.rs

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