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
10type D = data::JustLut<Val>;
20
21type JaqFilterCache = HashMap<String, jaq_core::compile::Filter<Native<D>>>;
28
29#[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#[derive(Debug)]
54pub enum CommandOutcome {
55 Success {
57 output: String,
59 total: Option<u64>,
61 },
62 RawOutput(String),
65 UserError(String),
67}
68
69impl CommandOutcome {
70 #[must_use]
72 pub fn success(output: String) -> Self {
73 Self::Success {
74 output,
75 total: None,
76 }
77 }
78
79 #[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 #[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
112fn sanitize_control_chars(s: &str) -> String {
117 s.chars()
118 .filter(|&c| {
119 !c.is_control() || c == '\n' || c == '\t'
121 })
122 .collect()
123}
124
125#[must_use]
127pub fn format_success(format: Format, value: &serde_json::Value) -> String {
128 match format {
129 Format::Json => serde_json::to_string_pretty(value)
130 .expect("serializing serde_json::Value is infallible"),
131 Format::Text => {
132 let mut cache = JaqFilterCache::new();
133 sanitize_control_chars(&format_value_as_text(value, &mut cache))
134 }
135 }
136}
137
138#[must_use]
143pub fn format_output<T: Serialize>(format: Format, value: &T) -> String {
144 let json = serde_json::to_value(value).expect("derived Serialize impl should not fail");
145 format_success(format, &json)
146}
147
148#[must_use]
153pub fn build_envelope_value(
154 value: &serde_json::Value,
155 total: Option<u64>,
156 hints: &[crate::hints::Hint],
157) -> serde_json::Value {
158 let hints_json: Vec<serde_json::Value> = hints
159 .iter()
160 .map(|h| serde_json::json!({"description": &h.description, "cmd": &h.cmd}))
161 .collect();
162 let mut envelope = serde_json::json!({
163 "results": value,
164 "hints": hints_json,
165 });
166 if let Some(t) = total {
167 envelope["total"] = serde_json::json!(t);
168 }
169 envelope
170}
171
172#[must_use]
177pub fn format_envelope(
178 format: Format,
179 value: &serde_json::Value,
180 total: Option<u64>,
181 hints: &[crate::hints::Hint],
182) -> String {
183 match format {
184 Format::Json => {
185 let envelope = build_envelope_value(value, total, hints);
186 serde_json::to_string_pretty(&envelope)
187 .expect("serializing serde_json::Value is infallible")
188 }
189 Format::Text => {
190 let mut cache = JaqFilterCache::new();
191 let mut text = format_results_as_text(value, total, &mut cache);
192 if !hints.is_empty() {
193 text.push('\n');
194 for hint in hints {
195 text.push_str("\n -> ");
196 text.push_str(&hint.cmd);
197 text.push_str(" # ");
198 text.push_str(&hint.description);
199 }
200 }
201 sanitize_control_chars(&text)
202 }
203 }
204}
205
206fn format_results_as_text(
211 results: &serde_json::Value,
212 total: Option<u64>,
213 cache: &mut JaqFilterCache,
214) -> String {
215 if let (Some(total), serde_json::Value::Array(arr)) = (total, results) {
218 let is_tag_array = !arr.is_empty()
219 && arr.iter().all(|v| {
220 v.as_object().is_some_and(|m| {
221 m.contains_key("count") && m.contains_key("name") && m.len() == 2
222 })
223 });
224 if is_tag_array {
225 let tag_label = if total == 1 { "tag" } else { "tags" };
226 let header = format!("{total} unique {tag_label}");
227 let entries = format_value_as_text(results, cache);
228 return if entries.is_empty() {
229 header
230 } else {
231 format!("{header}\n{entries}")
232 };
233 }
234 }
235
236 let text = format_value_as_text(results, cache);
237 if let Some(total) = total {
238 let shown = match results {
239 serde_json::Value::Array(arr) => arr.len() as u64,
240 _ => return text,
241 };
242 if shown < total {
243 return format!("{text}\nshowing {shown} of {total} matches");
244 }
245 }
246 text
247}
248
249#[must_use]
251pub fn format_error(
252 format: Format,
253 error: &str,
254 path: Option<&str>,
255 hint: Option<&str>,
256 cause: Option<&str>,
257) -> String {
258 match format {
259 Format::Json => {
260 let mut obj = json!({"error": error});
261 if let Some(p) = path {
262 obj["path"] = json!(p);
263 }
264 if let Some(h) = hint {
265 obj["hint"] = json!(h);
266 }
267 if let Some(c) = cause {
268 obj["cause"] = json!(c);
269 }
270 serde_json::to_string_pretty(&obj).expect("serializing serde_json::Value is infallible")
271 }
272 Format::Text => {
273 let mut msg = format!("Error: {error}");
274 if let Some(p) = path {
275 let _ = write!(msg, "\n path: {p}");
276 }
277 if let Some(h) = hint {
278 let _ = write!(msg, "\n hint: {h}");
279 }
280 if let Some(c) = cause {
281 let _ = write!(msg, "\n cause: {c}");
282 }
283 sanitize_control_chars(&msg)
284 }
285 }
286}
287
288const PROPERTY_INFO_FILTER: &str = r#""\(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | join(", ")) + "]" else .value end)""#;
295
296const PROPERTY_SUMMARY_ENTRY_FILTER: &str =
298 r#""\(.name)\t\(.type)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;
299
300const TAG_SUMMARY_FILTER: &str = r#""\(.total) unique \(if .total == 1 then "tag" else "tags" end)\n\(.tags | map(" \(.name)\t\(.count) \(if .count == 1 then "file" else "files" end)") | join("\n"))""#;
302
303const TAG_SUMMARY_ENTRY_FILTER: &str =
305 r#""\(.name)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;
306
307const LINK_INFO_TARGET_FILTER: &str = r#"" \"\(.target)\" (unresolved)""#;
310
311const LINK_INFO_PATH_FILTER: &str = r#"" \"\(.target)\" → \"\(.path)\"""#;
314
315const LINK_INFO_LABEL_FILTER: &str = r#"" \"\(.target)\" (unresolved) [\(.label)]""#;
318
319const LINK_INFO_FULL_FILTER: &str = r#"" \"\(.target)\" → \"\(.path)\" [\(.label)]""#;
322
323const TASK_COUNT_FILTER: &str = r#""[\(.done)/\(.total)]""#;
325
326const OUTLINE_SECTION_FILTER: &str = r##""\("#" * .level) \(.heading // "(pre-heading)")\(if (.links | length) > 0 then "\n\(.links | map(" → \"\(.)\"") | join("\n"))" else "" end)""##;
328
329const OUTLINE_SECTION_WITH_TASKS_FILTER: &str = r##""\("#" * .level) \(.heading // "(pre-heading)") [\(.tasks.done)/\(.tasks.total)]\(if (.links | length) > 0 then "\n\(.links | map(" → \"\(.)\"") | join("\n"))" else "" end)""##;
331
332const TASK_INFO_FILTER: &str =
334 r#""line \(.line): [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;
335
336const TASK_READ_RESULT_FILTER: &str =
338 r#""\"\(.file)\":\(.line) [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;
339
340const TASK_DRY_RUN_RESULT_FILTER: &str =
344 r#""\"\(.file)\":\(.line) [\(.old_status)] -> [\(.status)] \(.text)""#;
345
346const VAULT_SUMMARY_FILTER: &str = r#""Files: \(.files.total)\nDirectories: \(if (.files.directories | length) > 0 then (.files.directories | .[:7] | map("\(.directory)/ (\(.count))") | join(", ")) + (if (.files.directories | length) > 7 then ", ..." else "" end) else "(none)" end)\nProperties: \(.properties | length) — \(if (.properties | length) > 0 then (.properties | sort_by(-.count) | .[:7] | map("\(.name) (\(.count))") | join(", ")) + (if (.properties | length) > 7 then ", ..." else "" end) else "(none)" end)\nTags: \(.tags.total) — \(if (.tags.tags | length) > 0 then (.tags.tags | .[:7] | map("\(.name) (\(.count))") | join(", ")) + (if (.tags.tags | length) > 7 then ", ..." else "" end) else "(none)" end)\nTasks: \(.tasks.done)/\(.tasks.total)\nLinks: \(.links.total) total, \(.links.broken) broken\nOrphans: \(.orphans)\nDead-ends: \(.dead_ends)\nStatus: \(if (.status | length) > 0 then (.status | sort_by(-.count) | map("\(.value) (\(.count))") | join(", ")) else "(none)" end)\nRecent: \(if (.recent_files | length) > 0 then (.recent_files | map(.path) | join(", ")) else "(none)" end)""#;
349
350const FIND_TASK_INFO_FILTER: &str =
353 r#"" [\(if .done then "x" else " " end)] \(.text) (line \(.line), \(.section))""#;
354
355const CONTENT_MATCH_FILTER: &str = r#"" line \(.line) (\(.section)): \(.text)""#;
358
359const PROPERTY_VALUE_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.property)=\(.value): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map(" \"\(.)\"") | join("\n"))" else "" end)""#;
365
366const PROPERTY_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.property): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map(" \"\(.)\"") | join("\n"))" else "" end)""#;
372
373const TAG_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.tag): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map(" \"\(.)\"") | join("\n"))" else "" end)""#;
379
380const BACKLINKS_RESULT_FILTER: &str = r#"if (.backlinks | length) == 0 then "No backlinks found for \"\(.file)\"" else "\(.backlinks | length) \(if (.backlinks | length) == 1 then "backlink" else "backlinks" end) for \"\(.file)\"\n\(.backlinks | map(" \(.source): line \(.line)") | join("\n"))" end"#;
384
385const LINKS_FIX_FILTER: &str = r#""Broken links: \(.broken)\nFixable: \(.fixable)\nUnfixable: \(.unfixable)\nIgnored: \(.ignored)\(if .case_mismatches > 0 then "\nCase mismatches: \(.case_mismatches)" else "" end)\nApplied: \(if .applied then "yes" else "no" end)\(if (.fixes | length) > 0 then "\n\(.fixes | map(" \(.source) line \(.line): \"\(.old_target)\" → \"\(.new_target)\"") | join("\n"))" else "" end)\(if (.case_mismatch_fixes | length) > 0 then "\nCase-mismatch fixes:\n\(.case_mismatch_fixes | map(" \(.source) line \(.line): \"\(.old_target)\" → \"\(.new_target)\" [link-case-mismatch]") | join("\n"))" else "" end)""#;
388
389const LINKS_AUTO_FILTER: &str = r#""\(.total) unlinked mention\(if .total == 1 then "" else "s" end) found in \(.matches | map(.file) | unique | length) file\(if (.matches | map(.file) | unique | length) == 1 then "" else "s" end) (\(.scanned) scanned)\(if (.ambiguous_titles | length) > 0 then " (\(.ambiguous_titles | length) ambiguous title\(if (.ambiguous_titles | length) == 1 then "" else "s" end) skipped)" else "" end)\nApplied: \(if .applied then "yes" else "no" end)\(if (.matches | length) > 0 then "\n\(.matches | map(" \(.file):\(.line) \"\(.matched_text)\" → [[\(.link_target)]]") | join("\n"))" else "" end)""#;
392
393const MV_RESULT_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)Moved \(.from) → \(.to)\(.updated_files | if length > 0 then "\n" + (map(" \(.file): " + (.replacements | map(.old_text + " → " + .new_text) | join(", "))) | join("\n")) else "" end)""#;
396
397const VIEWS_LIST_ENTRY_FILTER: &str = r#""\(.name)\t\(.filters | to_entries | map("\(.key)=\(.value | if type == "array" then join(",") else tostring end)") | join(" "))""#;
400
401const VIEWS_MUTATION_RESULT_FILTER: &str = r#""\(.action): \(.name)""#;
404
405fn key_signature(map: &serde_json::Map<String, serde_json::Value>) -> String {
411 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
412 keys.sort_unstable();
413 keys.join(",")
414}
415
416fn lookup_filter(key_sig: &str) -> Option<&'static str> {
420 match key_sig {
421 "name,type,value" => Some(PROPERTY_INFO_FILTER),
423 "count,name,type" => Some(PROPERTY_SUMMARY_ENTRY_FILTER),
425 "tags,total" => Some(TAG_SUMMARY_FILTER),
427 "count,name" => Some(TAG_SUMMARY_ENTRY_FILTER),
429 "target" => Some(LINK_INFO_TARGET_FILTER),
431 "path,target" => Some(LINK_INFO_PATH_FILTER),
432 "label,target" => Some(LINK_INFO_LABEL_FILTER),
433 "label,path,target" => Some(LINK_INFO_FULL_FILTER),
434 "done,total" => Some(TASK_COUNT_FILTER),
436 "code_blocks,heading,level,line,links" => Some(OUTLINE_SECTION_FILTER),
438 "code_blocks,heading,level,line,links,tasks" => Some(OUTLINE_SECTION_WITH_TASKS_FILTER),
439 "done,line,status,text" => Some(TASK_INFO_FILTER),
441 "done,line,section,status,text" => Some(FIND_TASK_INFO_FILTER),
443 "line,section,text" => Some(CONTENT_MATCH_FILTER),
445 "done,file,line,status,text" => Some(TASK_READ_RESULT_FILTER),
447 "done,file,line,old_status,status,text" => Some(TASK_DRY_RUN_RESULT_FILTER),
449 "dead_ends,files,links,orphans,properties,recent_files,status,tags,tasks"
451 | "dead_ends,files,links,orphans,properties,recent_files,schema,status,tags,tasks" => {
452 Some(VAULT_SUMMARY_FILTER)
453 }
454 "dry_run,modified,property,scanned,skipped,total,value" => {
457 Some(PROPERTY_VALUE_MUTATION_FILTER)
458 }
459 "dry_run,modified,property,scanned,skipped,total" => Some(PROPERTY_MUTATION_FILTER),
461 "dry_run,modified,scanned,skipped,tag,total" => Some(TAG_MUTATION_FILTER),
463 "backlinks,file" => Some(BACKLINKS_RESULT_FILTER),
465 "applied,broken,case_mismatch_fixes,case_mismatches,fixable,fixes,ignored,unfixable,unfixable_links" => {
467 Some(LINKS_FIX_FILTER)
468 }
469 "ambiguous_titles,applied,matches,scanned,total" => Some(LINKS_AUTO_FILTER),
471 "dry_run,from,to,total_files_updated,total_links_updated,updated_files" => {
473 Some(MV_RESULT_FILTER)
474 }
475 "filters,name" => Some(VIEWS_LIST_ENTRY_FILTER),
477 "action,name" => Some(VIEWS_MUTATION_RESULT_FILTER),
479 _ => None,
480 }
481}
482
483fn apply_jq_filter(
493 filter_code: &str,
494 value: &serde_json::Value,
495 cache: &mut JaqFilterCache,
496) -> Option<String> {
497 run_jq_filter_cached(filter_code, value, cache).ok()
498}
499
500pub fn apply_jq_filter_result(
508 filter_code: &str,
509 value: &serde_json::Value,
510) -> Result<String, String> {
511 let filter = compile_jq_filter(filter_code)?;
512 execute_jq_filter(&filter, value)
513}
514
515fn format_load_errors(errs: &load::Errors<&str, ()>) -> String {
520 for (_file, err) in errs {
523 match err {
524 load::Error::Io(ios) => {
525 if let Some((_path, msg)) = ios.first() {
526 return format!("jq filter error (IO): {msg}");
527 }
528 }
529 load::Error::Lex(lex_errs) => {
530 if let Some((expect, span)) = lex_errs.first() {
531 return format!(
532 "jq filter syntax error: expected {} near {:?}",
533 expect.as_str(),
534 span
535 );
536 }
537 }
538 load::Error::Parse(parse_errs) => {
539 if let Some((expect, _token)) = parse_errs.first() {
540 return format!("jq filter parse error: expected {}", expect.as_str());
541 }
542 }
543 }
544 }
545 "jq filter error: invalid filter syntax".to_owned()
546}
547
548fn compile_jq_filter(filter_code: &str) -> Result<jaq_core::compile::Filter<Native<D>>, String> {
553 let program = File {
554 code: filter_code,
555 path: (),
556 };
557 let defs = jaq_core::defs()
558 .chain(jaq_std::defs())
559 .chain(jaq_json::defs());
560 let loader = Loader::new(defs);
561 let arena = Arena::default();
562
563 let modules = loader
564 .load(&arena, program)
565 .map_err(|errs| format_load_errors(&errs))?;
566
567 let funs = jaq_core::funs::<D>()
568 .chain(jaq_std::funs::<D>())
569 .chain(jaq_json::funs::<D>());
570 Compiler::default()
571 .with_funs(funs)
572 .compile(modules)
573 .map_err(|errs| {
574 let first = errs.iter().flat_map(|(_file, undefs)| undefs.iter()).next();
577 if let Some((name, undef)) = first {
578 format!("jq filter error: undefined {} {:?}", undef.as_str(), name)
579 } else {
580 "jq filter error: compilation failed".to_owned()
581 }
582 })
583}
584
585const JQ_OUTPUT_CAP: usize = 10 * 1024 * 1024; fn execute_jq_filter(
591 filter: &jaq_core::compile::Filter<Native<D>>,
592 value: &serde_json::Value,
593) -> Result<String, String> {
594 let input: Val = serde_json::from_value(value.clone())
595 .map_err(|e| format!("jq input conversion error: {e}"))?;
596 let ctx = Ctx::<D>::new(&filter.lut, Vars::new([]));
597
598 let mut out = String::new();
599 let mut total_len: usize = 0;
600 for result in filter.id.run((ctx, input)).map(jaq_core::unwrap_valr) {
601 match result {
602 Ok(val) => {
603 let s = match val {
604 Val::TStr(ref s) | Val::BStr(ref s) => match std::str::from_utf8(s) {
605 Ok(valid) => valid.to_owned(),
606 Err(_) => String::from_utf8_lossy(s).into_owned(),
607 },
608 other => other.to_string(),
611 };
612 total_len = total_len
615 .saturating_add(s.len())
616 .saturating_add(usize::from(!out.is_empty()));
617 if total_len > JQ_OUTPUT_CAP {
618 return Err(format!(
619 "jq filter output exceeds {} MiB limit",
620 JQ_OUTPUT_CAP / (1024 * 1024)
621 ));
622 }
623 if !out.is_empty() {
624 out.push('\n');
625 }
626 out.push_str(&s);
627 }
628 Err(e) => return Err(format!("jq runtime error: {e}")),
629 }
630 }
631
632 Ok(out)
633}
634
635fn run_jq_filter_cached(
637 filter_code: &str,
638 value: &serde_json::Value,
639 cache: &mut JaqFilterCache,
640) -> Result<String, String> {
641 if let Some(filter) = cache.get(filter_code) {
642 return execute_jq_filter(filter, value);
643 }
644 let compiled = compile_jq_filter(filter_code)?;
645 let filter = cache.entry(filter_code.to_owned()).or_insert(compiled);
646 execute_jq_filter(filter, value)
647}
648
649fn build_file_object_filter(map: &serde_json::Map<String, serde_json::Value>) -> String {
666 let mut parts = vec![r#""\"\(.file)\" (\(.modified))""#.to_owned()];
668
669 if map.contains_key("title") {
671 parts.push(r#"" title: \(if .title != null then .title else "(none)" end)""#.to_owned());
672 }
673
674 if map.contains_key("properties") {
676 parts.push(
677 r#"if (.properties | length) > 0 then " properties:\n\(.properties | to_entries | map(" \(.key): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
678 );
679 }
680
681 if map.contains_key("properties_typed") {
683 parts.push(
684 r#"if (.properties_typed | length) > 0 then " properties_typed:\n\(.properties_typed | map(" \(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
685 );
686 }
687
688 if map.contains_key("tags") {
690 parts.push(
691 r#"if (.tags | length) > 0 then " tags: [\(.tags | join(", "))]" else empty end"#
692 .to_owned(),
693 );
694 }
695
696 if map.contains_key("sections") {
699 parts.push(
700 r##"if (.sections | length) > 0 then " sections:\n\(.sections | map(" \("#" * .level) \(.heading // "(pre-heading)")\(if .tasks then " [\(.tasks.done)/\(.tasks.total)]" else "" end)") | join("\n"))" else empty end"##.to_owned(),
701 );
702 }
703
704 if map.contains_key("tasks") {
706 parts.push(
707 r#"if (.tasks | length) > 0 then " tasks:\n\(.tasks | map(" [\(if .done then "x" else " " end)] \(.text) (line \(.line))") | join("\n"))" else empty end"#.to_owned(),
708 );
709 }
710
711 if map.contains_key("matches") {
713 parts.push(
714 r#"if (.matches | length) > 0 then " matches:\n\(.matches | map(" line \(.line) (\(.section)): \(.text)") | join("\n"))" else empty end"#.to_owned(),
715 );
716 }
717
718 if map.contains_key("score") {
720 parts.push(r#"" score: \(.score)""#.to_owned());
721 }
722
723 if map.contains_key("links") {
725 parts.push(
726 r#"if (.links | length) > 0 then " links:\n\(.links | map(" \"\(.target)\"\(if .path then " → \"\(.path)\"" else " (unresolved)" end)") | join("\n"))" else empty end"#.to_owned(),
727 );
728 }
729
730 if map.contains_key("backlinks") {
732 parts.push(
733 r#"if (.backlinks | length) > 0 then " backlinks:\n\(.backlinks | map(" \"\(.source)\" line \(.line)\(if .label then ": \(.label)" else "" end)") | join("\n"))" else empty end"#.to_owned(),
734 );
735 }
736
737 parts.join(", ")
738}
739
740fn format_value_as_text(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
746 match value {
747 serde_json::Value::Array(arr) => {
748 let is_type_list = arr.first().and_then(|v| v.as_object()).is_some_and(|m| {
750 key_signature(m) == "has_filename_template,property_count,required,type"
751 });
752 if is_type_list {
753 return arr
754 .iter()
755 .filter_map(|v| v.as_object())
756 .map(format_type_list_entry_text)
757 .collect::<Vec<_>>()
758 .join("\n\n");
759 }
760 let is_file_objects = arr
762 .first()
763 .and_then(|v| v.as_object())
764 .is_some_and(|m| m.contains_key("file") && m.contains_key("modified"));
765 let sep = if is_file_objects { "\n\n" } else { "\n" };
766 arr.iter()
767 .map(|v| format_value_as_text(v, cache))
768 .collect::<Vec<_>>()
769 .join(sep)
770 }
771 serde_json::Value::Object(map) => {
772 let sig = key_signature(map);
773 if let Some(filter) = lookup_filter(&sig)
774 && let Some(output) = apply_jq_filter(filter, value, cache)
775 {
776 return output;
777 }
778 if sig == "defaults,filename_template,properties,required,type" {
780 return format_type_show_text(map);
781 }
782 if map.contains_key("total")
784 && map.contains_key("files")
785 && let Some(serde_json::Value::Array(arr)) = map.get("files")
786 {
787 let is_lint = arr
788 .first()
789 .and_then(|v| v.as_object())
790 .is_some_and(|m| m.contains_key("file") && m.contains_key("violations"))
791 || arr.is_empty();
792 if is_lint {
793 return format_lint_output_text(map);
794 }
795 }
796 if map.contains_key("file") && map.contains_key("modified") {
798 let filter = build_file_object_filter(map);
799 if let Some(output) = apply_jq_filter(&filter, value, cache) {
800 return output;
801 }
802 }
803 format_object_generic(map, cache)
805 }
806 other => format_scalar(other, cache),
807 }
808}
809
810fn format_lint_output_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
814 use std::fmt::Write as _;
815
816 let mut s = String::new();
817 let dry_run = map
818 .get("dry_run")
819 .and_then(serde_json::Value::as_bool)
820 .unwrap_or(false);
821
822 if let Some(fixes_arr) = map.get("fixes").and_then(|f| f.as_array()) {
824 let verb = if dry_run { "Would fix" } else { "Fixed" };
825 for file_fix in fixes_arr {
826 let file = file_fix
827 .get("file")
828 .and_then(serde_json::Value::as_str)
829 .unwrap_or("?");
830 let actions = file_fix.get("actions").and_then(|a| a.as_array());
831 let Some(actions) = actions else { continue };
832 if actions.is_empty() {
833 continue;
834 }
835 let _ = writeln!(s, "{verb} {file}:");
836 for a in actions {
837 let kind = a
838 .get("kind")
839 .and_then(serde_json::Value::as_str)
840 .unwrap_or("");
841 let property = a
842 .get("property")
843 .and_then(serde_json::Value::as_str)
844 .unwrap_or("?");
845 let new = a
846 .get("new")
847 .and_then(serde_json::Value::as_str)
848 .unwrap_or("");
849 let old = a.get("old").and_then(serde_json::Value::as_str);
850 match (kind, old) {
851 ("insert-default", _) => {
852 let _ = writeln!(s, " insert {property} = {new:?}");
853 }
854 ("infer-type", _) => {
855 let _ = writeln!(s, " infer type = {new:?}");
856 }
857 ("fix-enum-typo", Some(old_v)) => {
858 let _ = writeln!(s, " enum {property}: {old_v:?} -> {new:?}");
859 }
860 ("normalize-date", Some(old_v)) => {
861 let _ = writeln!(s, " date {property}: {old_v:?} -> {new:?}");
862 }
863 _ => {
864 let _ = writeln!(s, " {kind} {property} = {new:?}");
865 }
866 }
867 }
868 }
869 }
870
871 let files = map.get("files").and_then(|f| f.as_array());
873 if let Some(files) = files {
874 for file_entry in files {
875 let file = file_entry
876 .get("file")
877 .and_then(serde_json::Value::as_str)
878 .unwrap_or("?");
879 let violations = file_entry.get("violations").and_then(|v| v.as_array());
880 let Some(violations) = violations else {
881 continue;
882 };
883 if violations.is_empty() {
884 continue;
885 }
886 let _ = writeln!(s, "{file}:");
887 for v in violations {
888 let severity = v
889 .get("severity")
890 .and_then(serde_json::Value::as_str)
891 .unwrap_or("warn");
892 let message = v
893 .get("message")
894 .and_then(serde_json::Value::as_str)
895 .unwrap_or("");
896 let pad = if severity == "error" {
897 "error"
898 } else {
899 "warn "
900 };
901 let _ = writeln!(s, " {pad} {message}");
902 }
903 }
904 }
905
906 let error_count: u64 = map
907 .get("errors")
908 .and_then(serde_json::Value::as_u64)
909 .unwrap_or(0);
910 let warn_count: u64 = map
911 .get("warnings")
912 .and_then(serde_json::Value::as_u64)
913 .unwrap_or(0);
914 let files_with_issues: u64 = map
915 .get("files_with_issues")
916 .and_then(serde_json::Value::as_u64)
917 .unwrap_or(0);
918 let limited = map
919 .get("limited")
920 .and_then(serde_json::Value::as_bool)
921 .unwrap_or(false);
922 let shown_files = map
923 .get("files")
924 .and_then(|f| f.as_array())
925 .map_or(0, |arr| {
926 arr.iter()
927 .filter(|e| {
928 e.get("violations")
929 .and_then(|v| v.as_array())
930 .is_some_and(|v| !v.is_empty())
931 })
932 .count()
933 });
934
935 if limited {
936 let _ = writeln!(
937 s,
938 "… (showing {shown_files} of {files_with_issues} files with issues)"
939 );
940 }
941
942 let files_checked: u64 = map
944 .get("files_checked")
945 .and_then(serde_json::Value::as_u64)
946 .unwrap_or(0);
947 let files_label = if files_checked == 1 { "file" } else { "files" };
948 if error_count == 0 && warn_count == 0 {
949 let _ = write!(s, "{files_checked} {files_label} checked, no issues");
950 } else {
951 let _ = write!(
952 s,
953 "{files_checked} {files_label} checked, {files_with_issues} with issues ({error_count} errors, {warn_count} warnings)",
954 );
955 }
956
957 let fix_count: usize = map
958 .get("fixes")
959 .and_then(|f| f.as_array())
960 .map_or(0, |arr| {
961 arr.iter()
962 .filter_map(|f| f.get("actions").and_then(|a| a.as_array()).map(Vec::len))
963 .sum()
964 });
965 if fix_count > 0 {
966 let fixed_label = if dry_run { "would fix" } else { "fixed" };
967 let _ = write!(s, " — {fixed_label} {fix_count}");
968 }
969
970 s
971}
972
973fn format_type_show_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
993 use std::fmt::Write as _;
994
995 let mut s = String::new();
996
997 let type_name = map
998 .get("type")
999 .and_then(serde_json::Value::as_str)
1000 .unwrap_or("?");
1001 let _ = write!(s, "Type: {type_name}");
1002
1003 if let Some(serde_json::Value::Array(req)) = map.get("required")
1005 && !req.is_empty()
1006 {
1007 let list: Vec<&str> = req.iter().filter_map(serde_json::Value::as_str).collect();
1008 let _ = write!(s, "\n\nRequired: {}", list.join(", "));
1009 }
1010
1011 if let Some(serde_json::Value::Object(defaults)) = map.get("defaults")
1013 && !defaults.is_empty()
1014 {
1015 let _ = write!(s, "\n\nDefaults:");
1016 let mut keys: Vec<&str> = defaults.keys().map(String::as_str).collect();
1017 keys.sort_unstable();
1018 for key in keys {
1019 if let Some(value) = defaults.get(key) {
1020 let display = match value {
1021 serde_json::Value::String(sv) => sv.clone(),
1022 other => other.to_string(),
1023 };
1024 let _ = write!(s, "\n {key}: {display}");
1025 }
1026 }
1027 }
1028
1029 if let Some(serde_json::Value::Object(props)) = map.get("properties")
1031 && !props.is_empty()
1032 {
1033 let _ = write!(s, "\n\nProperties:");
1034 let mut prop_names: Vec<&str> = props.keys().map(String::as_str).collect();
1035 prop_names.sort_unstable();
1036 for name in prop_names {
1037 let Some(prop_val) = props.get(name) else {
1038 continue;
1039 };
1040 let _ = write!(s, "\n {name}:");
1041 if let Some(obj) = prop_val.as_object() {
1042 let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
1045 keys.sort_unstable_by(|a, b| {
1046 if *a == "type" {
1047 std::cmp::Ordering::Less
1048 } else if *b == "type" {
1049 std::cmp::Ordering::Greater
1050 } else {
1051 a.cmp(b)
1052 }
1053 });
1054 for key in keys {
1055 if let Some(v) = obj.get(key) {
1056 let display = match v {
1057 serde_json::Value::Array(arr) => arr
1058 .iter()
1059 .filter_map(serde_json::Value::as_str)
1060 .collect::<Vec<_>>()
1061 .join(", "),
1062 serde_json::Value::String(sv) => sv.clone(),
1063 other => other.to_string(),
1064 };
1065 let _ = write!(s, "\n {key}: {display}");
1066 }
1067 }
1068 }
1069 s.push('\n'); }
1071 }
1072
1073 if let Some(serde_json::Value::String(tmpl)) = map.get("filename_template") {
1075 let _ = write!(s, "\nFilename template: {tmpl}");
1076 }
1077
1078 s
1079}
1080
1081fn format_type_list_entry_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
1093 use std::fmt::Write as _;
1094
1095 let mut s = String::new();
1096
1097 let type_name = map
1098 .get("type")
1099 .and_then(serde_json::Value::as_str)
1100 .unwrap_or("?");
1101
1102 let req_arr: &[serde_json::Value] = map
1103 .get("required")
1104 .and_then(serde_json::Value::as_array)
1105 .map_or(&[], Vec::as_slice);
1106 let req_count = req_arr.len();
1107
1108 let prop_count = map
1109 .get("property_count")
1110 .and_then(serde_json::Value::as_u64)
1111 .unwrap_or(0);
1112
1113 let has_filename = map
1114 .get("has_filename_template")
1115 .and_then(serde_json::Value::as_bool)
1116 .unwrap_or(false);
1117
1118 let prop_label = if prop_count == 1 {
1119 "property"
1120 } else {
1121 "properties"
1122 };
1123 let _ = write!(
1124 s,
1125 "{type_name} ({prop_count} {prop_label}, {req_count} required)"
1126 );
1127
1128 if !req_arr.is_empty() {
1129 let list: Vec<&str> = req_arr
1130 .iter()
1131 .filter_map(serde_json::Value::as_str)
1132 .collect();
1133 let _ = write!(s, "\n required: {}", list.join(", "));
1134 }
1135
1136 if has_filename {
1137 let _ = write!(s, "\n filename: (see type details)");
1138 }
1139
1140 s
1141}
1142
1143fn format_object_generic(
1145 map: &serde_json::Map<String, serde_json::Value>,
1146 cache: &mut JaqFilterCache,
1147) -> String {
1148 map.iter()
1149 .map(|(k, v)| format!("{k}: {}", format_value_as_text(v, cache)))
1150 .collect::<Vec<_>>()
1151 .join("\n")
1152}
1153
1154fn format_scalar(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
1156 match value {
1157 serde_json::Value::String(s) => s.clone(),
1158 serde_json::Value::Number(n) => n.to_string(),
1159 serde_json::Value::Bool(b) => b.to_string(),
1160 serde_json::Value::Null => "null".to_owned(),
1161 serde_json::Value::Array(arr) => {
1162 let items: Vec<String> = arr.iter().map(|v| format_scalar(v, cache)).collect();
1163 items.join(", ")
1164 }
1165 serde_json::Value::Object(_) => format_value_as_text(value, cache),
1166 }
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172 use serde_json::json;
1173
1174 fn jq(filter: &str, val: &serde_json::Value) -> Option<String> {
1176 apply_jq_filter(filter, val, &mut JaqFilterCache::new())
1177 }
1178
1179 fn fmt(val: &serde_json::Value) -> String {
1180 format_value_as_text(val, &mut JaqFilterCache::new())
1181 }
1182
1183 fn scalar(val: &serde_json::Value) -> String {
1184 format_scalar(val, &mut JaqFilterCache::new())
1185 }
1186
1187 #[test]
1190 fn format_json_error() {
1191 let out = format_error(
1192 Format::Json,
1193 "file not found",
1194 Some("foo/bar"),
1195 Some("did you mean foo/bar.md?"),
1196 None,
1197 );
1198 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1199 assert_eq!(parsed["error"], "file not found");
1200 assert_eq!(parsed["hint"], "did you mean foo/bar.md?");
1201 assert!(parsed.get("cause").is_none());
1202 }
1203
1204 #[test]
1205 fn format_text_error() {
1206 let out = format_error(Format::Text, "file not found", Some("foo"), None, None);
1207 assert!(out.contains("Error: file not found"));
1208 assert!(out.contains("path: foo"));
1209 }
1210
1211 #[test]
1212 fn format_json_success() {
1213 let val = json!({"name": "test", "value": 42});
1214 let out = format_success(Format::Json, &val);
1215 assert!(out.contains("\"name\": \"test\""));
1216 }
1217
1218 #[test]
1221 fn apply_jq_filter_simple() {
1222 let val = json!({"name": "hello", "count": 3});
1223 let result = jq(r#""\(.name): \(.count)""#, &val);
1224 assert_eq!(result.as_deref(), Some("hello: 3"));
1225 }
1226
1227 #[test]
1228 fn apply_jq_filter_array_map() {
1229 let val = json!(["a", "b", "c"]);
1230 let result = jq(".[]", &val);
1231 assert_eq!(result.as_deref(), Some("a\nb\nc"));
1232 }
1233
1234 #[test]
1235 fn apply_jq_filter_invalid_returns_none() {
1236 let val = json!({"x": 1});
1237 let result = jq("this is not valid jq %%%", &val);
1238 assert!(result.is_none());
1239 }
1240
1241 #[test]
1244 fn jq_output_cap_constant_is_10_mib() {
1245 assert_eq!(JQ_OUTPUT_CAP, 10 * 1024 * 1024);
1246 }
1247
1248 #[test]
1249 fn jq_output_within_cap_succeeds() {
1250 let val = json!({"msg": "hello"});
1252 let result = apply_jq_filter_result(".msg", &val);
1253 assert_eq!(result.as_deref(), Ok("hello"));
1254 }
1255
1256 #[test]
1257 fn jq_output_cap_triggers_on_large_output() {
1258 let big_string = "a".repeat(1000);
1261 let val = serde_json::Value::Array(
1262 std::iter::repeat_n(serde_json::Value::String(big_string), 11_000).collect(),
1263 );
1264 let result = apply_jq_filter_result(".[]", &val);
1266 assert!(result.is_err(), "expected cap error but got Ok output");
1267 let err = result.unwrap_err();
1268 assert!(
1269 err.contains("exceeds") && err.contains("MiB"),
1270 "unexpected error message: {err}"
1271 );
1272 }
1273
1274 #[test]
1277 fn property_info_filter() {
1278 let val = json!({"name": "title", "type": "text", "value": "My Note"});
1279 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
1280 assert!(out.contains("title"));
1281 assert!(out.contains("text"));
1282 assert!(out.contains("My Note"));
1283 }
1284
1285 #[test]
1286 fn property_info_filter_list_value() {
1287 let val = json!({"name": "tags", "type": "list", "value": ["rust", "cli"]});
1288 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
1289 assert!(out.contains("tags"));
1290 assert!(out.contains("list"));
1291 assert!(out.contains("[rust, cli]"), "expected [rust, cli]: {out}");
1293 assert!(!out.contains("[\"rust\""));
1294 }
1295
1296 #[test]
1297 fn property_summary_entry_filter() {
1298 let val = json!({"count": 7, "name": "title", "type": "text"});
1299 let out = jq(PROPERTY_SUMMARY_ENTRY_FILTER, &val).unwrap();
1300 assert!(out.contains("title"));
1301 assert!(out.contains("text"));
1302 assert!(out.contains("7 files"));
1303 }
1304
1305 #[test]
1306 fn tag_summary_filter() {
1307 let val = json!({
1308 "tags": [{"name": "rust", "count": 3}, {"name": "cli", "count": 1}],
1309 "total": 2
1310 });
1311 let out = jq(TAG_SUMMARY_FILTER, &val).unwrap();
1312 assert!(out.contains("2 unique tags"));
1313 assert!(out.contains("rust"));
1314 assert!(out.contains("3 files"));
1315 }
1316
1317 #[test]
1320 fn link_info_target_only_filter() {
1321 let val = json!({"target": "broken-link"});
1322 let out = jq(LINK_INFO_TARGET_FILTER, &val).unwrap();
1323 assert!(out.contains("broken-link"));
1324 assert!(out.contains("unresolved"));
1325 }
1326
1327 #[test]
1328 fn link_info_with_path_filter() {
1329 let val = json!({"path": "note-b.md", "target": "note-b"});
1330 let out = jq(LINK_INFO_PATH_FILTER, &val).unwrap();
1331 assert!(out.contains("note-b"));
1332 assert!(out.contains("note-b.md"));
1333 }
1334
1335 #[test]
1338 fn task_count_filter() {
1339 let val = json!({"done": 3, "total": 5});
1340 let out = jq(TASK_COUNT_FILTER, &val).unwrap();
1341 assert_eq!(out, "[3/5]");
1342 }
1343
1344 #[test]
1345 fn outline_section_filter() {
1346 let val = json!({
1347 "code_blocks": [],
1348 "heading": "Introduction",
1349 "level": 1,
1350 "line": 5,
1351 "links": ["[[other]]"]
1352 });
1353 let out = jq(OUTLINE_SECTION_FILTER, &val).unwrap();
1354 assert!(out.contains('#'));
1355 assert!(out.contains("Introduction"));
1356 assert!(out.contains("[[other]]"));
1357 }
1358
1359 #[test]
1360 fn outline_section_with_tasks_filter() {
1361 let val = json!({
1362 "code_blocks": [],
1363 "heading": "Tasks",
1364 "level": 2,
1365 "line": 10,
1366 "links": [],
1367 "tasks": {"done": 2, "total": 4}
1368 });
1369 let out = jq(OUTLINE_SECTION_WITH_TASKS_FILTER, &val).unwrap();
1370 assert!(out.contains("##"));
1371 assert!(out.contains("Tasks"));
1372 assert!(out.contains("[2/4]"));
1373 }
1374
1375 #[test]
1378 fn find_task_info_filter_done() {
1379 let val = json!({
1380 "done": true,
1381 "line": 42,
1382 "section": "Implementation",
1383 "status": "x",
1384 "text": "Write the tests"
1385 });
1386 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
1387 assert!(out.contains("[x]"));
1388 assert!(out.contains("Write the tests"));
1389 assert!(out.contains("line 42"));
1390 assert!(out.contains("Implementation"));
1391 }
1392
1393 #[test]
1394 fn find_task_info_filter_not_done() {
1395 let val = json!({
1396 "done": false,
1397 "line": 7,
1398 "section": "Todo",
1399 "status": " ",
1400 "text": "Review PR"
1401 });
1402 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
1403 assert!(out.contains("[ ]"));
1404 assert!(out.contains("Review PR"));
1405 assert!(out.contains("line 7"));
1406 assert!(out.contains("Todo"));
1407 }
1408
1409 #[test]
1410 fn find_task_info_via_format_value_as_text() {
1411 let val = json!({
1413 "done": true,
1414 "line": 5,
1415 "section": "Goals",
1416 "status": "x",
1417 "text": "Ship it"
1418 });
1419 let out = fmt(&val);
1420 assert!(out.contains("[x]"));
1421 assert!(out.contains("Ship it"));
1422 assert!(
1423 !out.contains("done: true"),
1424 "should not use generic fallback"
1425 );
1426 }
1427
1428 #[test]
1431 fn content_match_filter() {
1432 let val = json!({
1433 "line": 15,
1434 "section": "Background",
1435 "text": "This is the matching line"
1436 });
1437 let out = jq(CONTENT_MATCH_FILTER, &val).unwrap();
1438 assert!(out.contains("line 15"));
1439 assert!(out.contains("Background"));
1440 assert!(out.contains("This is the matching line"));
1441 }
1442
1443 #[test]
1444 fn content_match_via_format_value_as_text() {
1445 let val = json!({
1446 "line": 3,
1447 "section": "Intro",
1448 "text": "hello world"
1449 });
1450 let out = fmt(&val);
1451 assert!(out.contains("line 3"));
1452 assert!(out.contains("hello world"));
1453 assert!(!out.contains("line: 3"), "should not use generic fallback");
1454 }
1455
1456 #[test]
1459 fn property_value_mutation_filter_with_modified() {
1460 let val = json!({
1463 "modified": ["note-a.md", "note-b.md"],
1464 "property": "status",
1465 "scanned": 2,
1466 "skipped": [],
1467 "total": 2,
1468 "value": "done"
1469 });
1470 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1471 assert!(out.contains("status=done"));
1472 assert!(out.contains("2/2 modified"));
1473 assert!(
1474 !out.contains("scanned"),
1475 "no scanned suffix when scanned == total"
1476 );
1477 assert!(out.contains("note-a.md"));
1478 assert!(out.contains("note-b.md"));
1479 }
1480
1481 #[test]
1482 fn property_value_mutation_filter_all_skipped() {
1483 let val = json!({
1484 "modified": [],
1485 "property": "priority",
1486 "scanned": 1,
1487 "skipped": ["note-a.md"],
1488 "total": 1,
1489 "value": "high"
1490 });
1491 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1492 assert!(out.contains("priority=high"));
1493 assert!(out.contains("0/1 modified"));
1494 assert!(!out.contains("note-a.md"));
1496 }
1497
1498 #[test]
1499 fn property_value_mutation_filter_with_where_filter() {
1500 let val = json!({
1502 "modified": ["note-a.md"],
1503 "property": "status",
1504 "scanned": 5,
1505 "skipped": [],
1506 "total": 1,
1507 "value": "done"
1508 });
1509 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1510 assert!(out.contains("status=done"));
1511 assert!(out.contains("1/1 modified"));
1512 assert!(out.contains("(5 scanned)"));
1513 }
1514
1515 #[test]
1516 fn property_value_mutation_via_format_value_as_text() {
1517 let val = json!({
1518 "dry_run": false,
1519 "modified": ["notes/a.md"],
1520 "property": "status",
1521 "scanned": 1,
1522 "skipped": [],
1523 "total": 1,
1524 "value": "done"
1525 });
1526 let out = fmt(&val);
1527 assert!(out.contains("status=done"));
1528 assert!(
1529 !out.contains("modified: "),
1530 "should not use generic fallback"
1531 );
1532 }
1533
1534 #[test]
1535 fn property_mutation_filter_no_value() {
1536 let val = json!({
1538 "dry_run": false,
1539 "modified": ["note.md"],
1540 "property": "draft",
1541 "scanned": 1,
1542 "skipped": [],
1543 "total": 1
1544 });
1545 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1546 assert!(out.contains("draft"));
1547 assert!(out.contains("1/1 modified"));
1548 assert!(
1549 !out.contains("scanned"),
1550 "no scanned suffix when scanned == total"
1551 );
1552 assert!(out.contains("note.md"));
1553 }
1554
1555 #[test]
1556 fn property_mutation_filter_no_value_with_where_filter() {
1557 let val = json!({
1559 "dry_run": false,
1560 "modified": ["note.md"],
1561 "property": "draft",
1562 "scanned": 7,
1563 "skipped": [],
1564 "total": 1
1565 });
1566 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1567 assert!(out.contains("draft"));
1568 assert!(out.contains("1/1 modified"));
1569 assert!(out.contains("(7 scanned)"));
1570 }
1571
1572 #[test]
1573 fn tag_mutation_filter_with_modified() {
1574 let val = json!({
1576 "dry_run": false,
1577 "modified": ["a.md", "b.md"],
1578 "scanned": 3,
1579 "skipped": ["c.md"],
1580 "tag": "rust",
1581 "total": 3
1582 });
1583 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1584 assert!(out.contains("rust"));
1585 assert!(out.contains("2/3 modified"));
1586 assert!(
1587 !out.contains("scanned"),
1588 "no scanned suffix when scanned == total"
1589 );
1590 assert!(out.contains("a.md"));
1591 assert!(out.contains("b.md"));
1592 assert!(!out.contains("c.md"));
1593 }
1594
1595 #[test]
1596 fn tag_mutation_filter_with_where_filter() {
1597 let val = json!({
1599 "dry_run": false,
1600 "modified": ["a.md"],
1601 "scanned": 10,
1602 "skipped": [],
1603 "tag": "rust",
1604 "total": 1
1605 });
1606 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1607 assert!(out.contains("rust"));
1608 assert!(out.contains("1/1 modified"));
1609 assert!(out.contains("(10 scanned)"));
1610 }
1611
1612 #[test]
1613 fn tag_mutation_via_format_value_as_text() {
1614 let val = json!({
1615 "dry_run": false,
1616 "modified": [],
1617 "scanned": 1,
1618 "skipped": ["note.md"],
1619 "tag": "cli",
1620 "total": 1
1621 });
1622 let out = fmt(&val);
1623 assert!(out.contains("cli"));
1624 assert!(!out.contains("tag: cli"), "should not use generic fallback");
1625 }
1626
1627 #[test]
1630 fn property_value_mutation_dry_run_prefix() {
1631 let val = json!({
1632 "dry_run": true,
1633 "modified": ["note.md"],
1634 "property": "status",
1635 "scanned": 1,
1636 "skipped": [],
1637 "total": 1,
1638 "value": "done"
1639 });
1640 let out = fmt(&val);
1641 assert!(
1642 out.contains("[dry-run] status=done"),
1643 "dry-run prefix missing: {out}"
1644 );
1645 }
1646
1647 #[test]
1648 fn tag_mutation_dry_run_prefix() {
1649 let val = json!({
1650 "dry_run": true,
1651 "modified": ["note.md"],
1652 "scanned": 1,
1653 "skipped": [],
1654 "tag": "rust",
1655 "total": 1
1656 });
1657 let out = fmt(&val);
1658 assert!(
1659 out.contains("[dry-run] rust"),
1660 "dry-run prefix missing: {out}"
1661 );
1662 }
1663
1664 #[test]
1665 fn property_value_mutation_no_dry_run_prefix() {
1666 let val = json!({
1667 "dry_run": false,
1668 "modified": ["note.md"],
1669 "property": "status",
1670 "scanned": 1,
1671 "skipped": [],
1672 "total": 1,
1673 "value": "done"
1674 });
1675 let out = fmt(&val);
1676 assert!(
1677 !out.contains("[dry-run]"),
1678 "should not have dry-run prefix: {out}"
1679 );
1680 }
1681
1682 #[test]
1685 fn build_file_object_filter_minimal() {
1686 let map: serde_json::Map<String, serde_json::Value> =
1688 serde_json::from_str(r#"{"file": "notes/foo.md", "modified": "2024-01-01"}"#).unwrap();
1689 let filter = build_file_object_filter(&map);
1690 let val = json!({"file": "notes/foo.md", "modified": "2024-01-01"});
1691 let out = jq(&filter, &val).unwrap();
1692 assert!(out.contains("notes/foo.md"));
1693 assert!(out.contains("2024-01-01"));
1694 }
1695
1696 #[test]
1697 fn build_file_object_filter_with_tags() {
1698 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1699 r#"{"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]}"#,
1700 )
1701 .unwrap();
1702 let filter = build_file_object_filter(&map);
1703 let val = json!({"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]});
1704 let out = jq(&filter, &val).unwrap();
1705 assert!(out.contains("foo.md"));
1706 assert!(out.contains("tags: [rust, cli]"));
1707 }
1708
1709 #[test]
1710 fn build_file_object_filter_with_properties() {
1711 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1712 r#"{"file": "foo.md", "modified": "2024-01-01", "properties": {"status": "done"}}"#,
1713 )
1714 .unwrap();
1715 let filter = build_file_object_filter(&map);
1716 let val = json!({
1717 "file": "foo.md",
1718 "modified": "2024-01-01",
1719 "properties": {"status": "done"}
1720 });
1721 let out = jq(&filter, &val).unwrap();
1722 assert!(out.contains("foo.md"));
1723 assert!(out.contains("properties:"));
1724 assert!(out.contains("status: done"));
1725 }
1726
1727 #[test]
1728 fn build_file_object_filter_with_tasks() {
1729 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1730 r#"{"file": "foo.md", "modified": "2024-01-01", "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]}"#,
1731 )
1732 .unwrap();
1733 let filter = build_file_object_filter(&map);
1734 let val = json!({
1735 "file": "foo.md",
1736 "modified": "2024-01-01",
1737 "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]
1738 });
1739 let out = jq(&filter, &val).unwrap();
1740 assert!(out.contains("foo.md"));
1741 assert!(out.contains("tasks:"));
1742 assert!(out.contains("[x] Ship it"));
1743 assert!(out.contains("line 5"));
1744 }
1745
1746 #[test]
1747 fn build_file_object_filter_with_sections() {
1748 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1749 r#"{"file": "foo.md", "modified": "2024-01-01", "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]}"#,
1750 )
1751 .unwrap();
1752 let filter = build_file_object_filter(&map);
1753 let val = json!({
1754 "file": "foo.md",
1755 "modified": "2024-01-01",
1756 "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]
1757 });
1758 let out = jq(&filter, &val).unwrap();
1759 assert!(out.contains("foo.md"));
1760 assert!(out.contains("sections:"));
1761 assert!(out.contains("# Intro"));
1762 }
1763
1764 #[test]
1765 fn build_file_object_filter_with_matches() {
1766 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1767 r#"{"file": "foo.md", "modified": "2024-01-01", "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]}"#,
1768 )
1769 .unwrap();
1770 let filter = build_file_object_filter(&map);
1771 let val = json!({
1772 "file": "foo.md",
1773 "modified": "2024-01-01",
1774 "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]
1775 });
1776 let out = jq(&filter, &val).unwrap();
1777 assert!(out.contains("foo.md"));
1778 assert!(out.contains("matches:"));
1779 assert!(out.contains("line 3 (Intro): hello world"));
1780 }
1781
1782 #[test]
1783 fn build_file_object_filter_with_links() {
1784 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1785 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "bar", "path": "bar.md"}]}"#,
1786 )
1787 .unwrap();
1788 let filter = build_file_object_filter(&map);
1789 let val = json!({
1790 "file": "foo.md",
1791 "modified": "2024-01-01",
1792 "links": [{"target": "bar", "path": "bar.md"}]
1793 });
1794 let out = jq(&filter, &val).unwrap();
1795 assert!(out.contains("foo.md"));
1796 assert!(out.contains("links:"));
1797 assert!(out.contains(r#""bar" → "bar.md""#));
1798 }
1799
1800 #[test]
1801 fn build_file_object_filter_unresolved_link() {
1802 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1803 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "missing"}]}"#,
1804 )
1805 .unwrap();
1806 let filter = build_file_object_filter(&map);
1807 let val = json!({
1808 "file": "foo.md",
1809 "modified": "2024-01-01",
1810 "links": [{"target": "missing"}]
1811 });
1812 let out = jq(&filter, &val).unwrap();
1813 assert!(out.contains(r#""missing" (unresolved)"#));
1814 }
1815
1816 #[test]
1819 fn file_object_text_rendering_minimal() {
1820 let val = json!({"file": "notes/foo.md", "modified": "2024-01-15"});
1821 let out = fmt(&val);
1822 assert!(out.contains("notes/foo.md"));
1823 assert!(out.contains("2024-01-15"));
1824 assert!(!out.contains("file: notes/foo.md"));
1826 }
1827
1828 #[test]
1829 fn file_object_text_rendering_full() {
1830 let val = json!({
1831 "file": "notes/project.md",
1832 "modified": "2024-03-01",
1833 "tags": ["rust", "work"],
1834 "properties": {"status": "active"},
1835 "tasks": [
1836 {"done": false, "line": 10, "section": "Todo", "status": " ", "text": "Fix bug"},
1837 {"done": true, "line": 20, "section": "Done", "status": "x", "text": "Write docs"}
1838 ]
1839 });
1840 let out = fmt(&val);
1841 assert!(out.contains("notes/project.md"));
1842 assert!(out.contains("properties:"));
1843 assert!(out.contains("status: active"));
1844 assert!(out.contains("tags: [rust, work]"));
1845 assert!(out.contains("tasks:"));
1846 assert!(out.contains("[ ] Fix bug"));
1847 assert!(out.contains("[x] Write docs"));
1848 }
1849
1850 #[test]
1853 fn array_of_file_objects_uses_blank_line_separator() {
1854 let val = json!([
1855 {"file": "a.md", "modified": "2024-01-01"},
1856 {"file": "b.md", "modified": "2024-01-02"}
1857 ]);
1858 let out = fmt(&val);
1859 assert!(out.contains("a.md"));
1860 assert!(out.contains("b.md"));
1861 assert!(
1863 out.contains("\n\n"),
1864 "expected blank-line separator between file objects"
1865 );
1866 }
1867
1868 #[test]
1869 fn array_of_non_file_objects_uses_single_newline() {
1870 let val = json!([
1871 {"count": 1, "name": "status", "type": "text"},
1872 {"count": 3, "name": "title", "type": "text"}
1873 ]);
1874 let out = fmt(&val);
1875 assert!(out.contains("status"));
1876 assert!(out.contains("title"));
1877 assert!(
1879 !out.contains("\n\n"),
1880 "non-file-objects should use single newline"
1881 );
1882 }
1883
1884 #[test]
1887 fn format_scalar_delegates_nested_objects() {
1888 let inner = json!({"count": 2, "name": "status", "type": "text"});
1891 let out = scalar(&inner);
1892 assert!(
1894 !out.contains("count=2"),
1895 "should delegate to format_value_as_text"
1896 );
1897 assert!(out.contains("status"));
1899 assert!(out.contains("2 files"));
1900 }
1901
1902 #[test]
1905 fn format_value_as_text_uses_filter_for_known_shape() {
1906 let val = json!({"count": 3, "name": "status", "type": "text"});
1908 let out = fmt(&val);
1909 assert!(out.contains("status"));
1910 assert!(out.contains("3 files"));
1911 assert!(!out.contains("count: 3"));
1913 }
1914
1915 #[test]
1916 fn format_value_as_text_falls_back_for_unknown_shape() {
1917 let val = json!({"foo": "bar", "baz": 42});
1918 let out = fmt(&val);
1919 assert!(out.contains("foo: bar") || out.contains("baz: 42"));
1921 }
1922
1923 #[test]
1924 fn mv_result_filter_applied() {
1925 let val = json!({
1926 "dry_run": false,
1927 "from": "sub/b.md",
1928 "to": "archive/b.md",
1929 "total_files_updated": 1,
1930 "total_links_updated": 1,
1931 "updated_files": [
1932 {
1933 "file": "a.md",
1934 "replacements": [
1935 {"old_text": "[[sub/b]]", "new_text": "[[archive/b]]", "line": 1}
1936 ]
1937 }
1938 ]
1939 });
1940 let sig = {
1942 let map = val.as_object().unwrap();
1943 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
1944 keys.sort_unstable();
1945 keys.join(",")
1946 };
1947 assert_eq!(
1948 sig,
1949 "dry_run,from,to,total_files_updated,total_links_updated,updated_files"
1950 );
1951 let filter_result = apply_jq_filter_result(MV_RESULT_FILTER, &val);
1953 assert!(filter_result.is_ok(), "filter error: {filter_result:?}");
1954 let out = filter_result.unwrap();
1955 assert!(out.contains("Moved sub/b.md"), "out: {out}");
1956 assert!(out.contains("archive/b.md"), "out: {out}");
1957 assert!(out.contains("[[sub/b]]"), "out: {out}");
1958 assert!(out.contains("[[archive/b]]"), "out: {out}");
1959 let found_filter =
1961 lookup_filter("dry_run,from,to,total_files_updated,total_links_updated,updated_files");
1962 assert!(
1963 found_filter.is_some(),
1964 "lookup_filter returned None for MvResult shape"
1965 );
1966 let formatted = fmt(&val);
1968 assert!(
1969 formatted.contains("Moved sub/b.md"),
1970 "formatted: {formatted}"
1971 );
1972 }
1973
1974 #[test]
1975 fn format_value_as_text_array_of_typed_objects() {
1976 let val = json!([
1977 {"path": "a.md", "tags": ["rust"]},
1978 {"path": "b.md", "tags": ["cli"]}
1979 ]);
1980 let out = fmt(&val);
1981 assert!(out.contains("a.md"));
1982 assert!(out.contains("b.md"));
1983 assert!(out.contains("rust"));
1984 assert!(out.contains("cli"));
1985 }
1986
1987 #[test]
1990 fn sanitize_control_chars_strips_escape_sequences() {
1991 let input = "Hello\x1b[31mRED\x1b[0m World";
1992 let output = sanitize_control_chars(input);
1993 assert!(
1994 !output.contains('\x1b'),
1995 "escape sequences should be stripped"
1996 );
1997 assert!(output.contains("Hello"));
1998 assert!(output.contains("RED"));
1999 assert!(output.contains("World"));
2000 }
2001
2002 #[test]
2003 fn sanitize_control_chars_preserves_newline_and_tab() {
2004 let input = "line1\nline2\ttabbed";
2005 let output = sanitize_control_chars(input);
2006 assert_eq!(output, input);
2007 }
2008
2009 #[test]
2010 fn text_output_sanitizes_escape_sequences() {
2011 let value = serde_json::json!({
2012 "results": {
2013 "title": "Hello\x1b[31mRED\x1b[0m World",
2014 "file": "test\x1b[2J.md"
2015 }
2016 });
2017 let output = format_success(Format::Text, &value);
2018 assert!(
2019 !output.contains('\x1b'),
2020 "escape sequences should be stripped"
2021 );
2022 assert!(output.contains("Hello") && output.contains("World"));
2023 }
2024}