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 MV_RESULT_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)Moved \(.from) → \(.to)\(.updated_files | if length > 0 then "\n" + (map(" \(.file): " + (.replacements | map(.old_text + " → " + .new_text) | join(", "))) | join("\n")) else "" end)""#;
392
393const VIEWS_LIST_ENTRY_FILTER: &str = r#""\(.name)\t\(.filters | to_entries | map("\(.key)=\(.value | if type == "array" then join(",") else tostring end)") | join(" "))""#;
396
397const VIEWS_MUTATION_RESULT_FILTER: &str = r#""\(.action): \(.name)""#;
400
401fn key_signature(map: &serde_json::Map<String, serde_json::Value>) -> String {
407 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
408 keys.sort_unstable();
409 keys.join(",")
410}
411
412fn lookup_filter(key_sig: &str) -> Option<&'static str> {
416 match key_sig {
417 "name,type,value" => Some(PROPERTY_INFO_FILTER),
419 "count,name,type" => Some(PROPERTY_SUMMARY_ENTRY_FILTER),
421 "tags,total" => Some(TAG_SUMMARY_FILTER),
423 "count,name" => Some(TAG_SUMMARY_ENTRY_FILTER),
425 "target" => Some(LINK_INFO_TARGET_FILTER),
427 "path,target" => Some(LINK_INFO_PATH_FILTER),
428 "label,target" => Some(LINK_INFO_LABEL_FILTER),
429 "label,path,target" => Some(LINK_INFO_FULL_FILTER),
430 "done,total" => Some(TASK_COUNT_FILTER),
432 "code_blocks,heading,level,line,links" => Some(OUTLINE_SECTION_FILTER),
434 "code_blocks,heading,level,line,links,tasks" => Some(OUTLINE_SECTION_WITH_TASKS_FILTER),
435 "done,line,status,text" => Some(TASK_INFO_FILTER),
437 "done,line,section,status,text" => Some(FIND_TASK_INFO_FILTER),
439 "line,section,text" => Some(CONTENT_MATCH_FILTER),
441 "done,file,line,status,text" => Some(TASK_READ_RESULT_FILTER),
443 "done,file,line,old_status,status,text" => Some(TASK_DRY_RUN_RESULT_FILTER),
445 "dead_ends,files,links,orphans,properties,recent_files,status,tags,tasks"
447 | "dead_ends,files,links,orphans,properties,recent_files,schema,status,tags,tasks" => {
448 Some(VAULT_SUMMARY_FILTER)
449 }
450 "dry_run,modified,property,scanned,skipped,total,value" => {
453 Some(PROPERTY_VALUE_MUTATION_FILTER)
454 }
455 "dry_run,modified,property,scanned,skipped,total" => Some(PROPERTY_MUTATION_FILTER),
457 "dry_run,modified,scanned,skipped,tag,total" => Some(TAG_MUTATION_FILTER),
459 "backlinks,file" => Some(BACKLINKS_RESULT_FILTER),
461 "applied,broken,case_mismatch_fixes,case_mismatches,fixable,fixes,ignored,unfixable,unfixable_links" => {
463 Some(LINKS_FIX_FILTER)
464 }
465 "dry_run,from,to,total_files_updated,total_links_updated,updated_files" => {
467 Some(MV_RESULT_FILTER)
468 }
469 "filters,name" => Some(VIEWS_LIST_ENTRY_FILTER),
471 "action,name" => Some(VIEWS_MUTATION_RESULT_FILTER),
473 _ => None,
474 }
475}
476
477fn apply_jq_filter(
487 filter_code: &str,
488 value: &serde_json::Value,
489 cache: &mut JaqFilterCache,
490) -> Option<String> {
491 run_jq_filter_cached(filter_code, value, cache).ok()
492}
493
494pub fn apply_jq_filter_result(
502 filter_code: &str,
503 value: &serde_json::Value,
504) -> Result<String, String> {
505 let filter = compile_jq_filter(filter_code)?;
506 execute_jq_filter(&filter, value)
507}
508
509fn format_load_errors(errs: &load::Errors<&str, ()>) -> String {
514 for (_file, err) in errs {
517 match err {
518 load::Error::Io(ios) => {
519 if let Some((_path, msg)) = ios.first() {
520 return format!("jq filter error (IO): {msg}");
521 }
522 }
523 load::Error::Lex(lex_errs) => {
524 if let Some((expect, span)) = lex_errs.first() {
525 return format!(
526 "jq filter syntax error: expected {} near {:?}",
527 expect.as_str(),
528 span
529 );
530 }
531 }
532 load::Error::Parse(parse_errs) => {
533 if let Some((expect, _token)) = parse_errs.first() {
534 return format!("jq filter parse error: expected {}", expect.as_str());
535 }
536 }
537 }
538 }
539 "jq filter error: invalid filter syntax".to_owned()
540}
541
542fn compile_jq_filter(filter_code: &str) -> Result<jaq_core::compile::Filter<Native<D>>, String> {
547 let program = File {
548 code: filter_code,
549 path: (),
550 };
551 let defs = jaq_core::defs()
552 .chain(jaq_std::defs())
553 .chain(jaq_json::defs());
554 let loader = Loader::new(defs);
555 let arena = Arena::default();
556
557 let modules = loader
558 .load(&arena, program)
559 .map_err(|errs| format_load_errors(&errs))?;
560
561 let funs = jaq_core::funs::<D>()
562 .chain(jaq_std::funs::<D>())
563 .chain(jaq_json::funs::<D>());
564 Compiler::default()
565 .with_funs(funs)
566 .compile(modules)
567 .map_err(|errs| {
568 let first = errs.iter().flat_map(|(_file, undefs)| undefs.iter()).next();
571 if let Some((name, undef)) = first {
572 format!("jq filter error: undefined {} {:?}", undef.as_str(), name)
573 } else {
574 "jq filter error: compilation failed".to_owned()
575 }
576 })
577}
578
579const JQ_OUTPUT_CAP: usize = 10 * 1024 * 1024; fn execute_jq_filter(
585 filter: &jaq_core::compile::Filter<Native<D>>,
586 value: &serde_json::Value,
587) -> Result<String, String> {
588 let input: Val = serde_json::from_value(value.clone())
589 .map_err(|e| format!("jq input conversion error: {e}"))?;
590 let ctx = Ctx::<D>::new(&filter.lut, Vars::new([]));
591
592 let mut out = String::new();
593 let mut total_len: usize = 0;
594 for result in filter.id.run((ctx, input)).map(jaq_core::unwrap_valr) {
595 match result {
596 Ok(val) => {
597 let s = match val {
598 Val::TStr(ref s) | Val::BStr(ref s) => match std::str::from_utf8(s) {
599 Ok(valid) => valid.to_owned(),
600 Err(_) => String::from_utf8_lossy(s).into_owned(),
601 },
602 other => other.to_string(),
605 };
606 total_len = total_len
609 .saturating_add(s.len())
610 .saturating_add(usize::from(!out.is_empty()));
611 if total_len > JQ_OUTPUT_CAP {
612 return Err(format!(
613 "jq filter output exceeds {} MiB limit",
614 JQ_OUTPUT_CAP / (1024 * 1024)
615 ));
616 }
617 if !out.is_empty() {
618 out.push('\n');
619 }
620 out.push_str(&s);
621 }
622 Err(e) => return Err(format!("jq runtime error: {e}")),
623 }
624 }
625
626 Ok(out)
627}
628
629fn run_jq_filter_cached(
631 filter_code: &str,
632 value: &serde_json::Value,
633 cache: &mut JaqFilterCache,
634) -> Result<String, String> {
635 if let Some(filter) = cache.get(filter_code) {
636 return execute_jq_filter(filter, value);
637 }
638 let compiled = compile_jq_filter(filter_code)?;
639 let filter = cache.entry(filter_code.to_owned()).or_insert(compiled);
640 execute_jq_filter(filter, value)
641}
642
643fn build_file_object_filter(map: &serde_json::Map<String, serde_json::Value>) -> String {
660 let mut parts = vec![r#""\"\(.file)\" (\(.modified))""#.to_owned()];
662
663 if map.contains_key("title") {
665 parts.push(r#"" title: \(if .title != null then .title else "(none)" end)""#.to_owned());
666 }
667
668 if map.contains_key("properties") {
670 parts.push(
671 r#"if (.properties | length) > 0 then " properties:\n\(.properties | to_entries | map(" \(.key): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
672 );
673 }
674
675 if map.contains_key("properties_typed") {
677 parts.push(
678 r#"if (.properties_typed | length) > 0 then " properties_typed:\n\(.properties_typed | map(" \(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
679 );
680 }
681
682 if map.contains_key("tags") {
684 parts.push(
685 r#"if (.tags | length) > 0 then " tags: [\(.tags | join(", "))]" else empty end"#
686 .to_owned(),
687 );
688 }
689
690 if map.contains_key("sections") {
693 parts.push(
694 r##"if (.sections | length) > 0 then " sections:\n\(.sections | map(" \("#" * .level) \(.heading // "(pre-heading)")\(if .tasks then " [\(.tasks.done)/\(.tasks.total)]" else "" end)") | join("\n"))" else empty end"##.to_owned(),
695 );
696 }
697
698 if map.contains_key("tasks") {
700 parts.push(
701 r#"if (.tasks | length) > 0 then " tasks:\n\(.tasks | map(" [\(if .done then "x" else " " end)] \(.text) (line \(.line))") | join("\n"))" else empty end"#.to_owned(),
702 );
703 }
704
705 if map.contains_key("matches") {
707 parts.push(
708 r#"if (.matches | length) > 0 then " matches:\n\(.matches | map(" line \(.line) (\(.section)): \(.text)") | join("\n"))" else empty end"#.to_owned(),
709 );
710 }
711
712 if map.contains_key("score") {
714 parts.push(r#"" score: \(.score)""#.to_owned());
715 }
716
717 if map.contains_key("links") {
719 parts.push(
720 r#"if (.links | length) > 0 then " links:\n\(.links | map(" \"\(.target)\"\(if .path then " → \"\(.path)\"" else " (unresolved)" end)") | join("\n"))" else empty end"#.to_owned(),
721 );
722 }
723
724 if map.contains_key("backlinks") {
726 parts.push(
727 r#"if (.backlinks | length) > 0 then " backlinks:\n\(.backlinks | map(" \"\(.source)\" line \(.line)\(if .label then ": \(.label)" else "" end)") | join("\n"))" else empty end"#.to_owned(),
728 );
729 }
730
731 parts.join(", ")
732}
733
734fn format_value_as_text(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
740 match value {
741 serde_json::Value::Array(arr) => {
742 let is_type_list = arr.first().and_then(|v| v.as_object()).is_some_and(|m| {
744 key_signature(m) == "has_filename_template,property_count,required,type"
745 });
746 if is_type_list {
747 return arr
748 .iter()
749 .filter_map(|v| v.as_object())
750 .map(format_type_list_entry_text)
751 .collect::<Vec<_>>()
752 .join("\n\n");
753 }
754 let is_file_objects = arr
756 .first()
757 .and_then(|v| v.as_object())
758 .is_some_and(|m| m.contains_key("file") && m.contains_key("modified"));
759 let sep = if is_file_objects { "\n\n" } else { "\n" };
760 arr.iter()
761 .map(|v| format_value_as_text(v, cache))
762 .collect::<Vec<_>>()
763 .join(sep)
764 }
765 serde_json::Value::Object(map) => {
766 let sig = key_signature(map);
767 if let Some(filter) = lookup_filter(&sig)
768 && let Some(output) = apply_jq_filter(filter, value, cache)
769 {
770 return output;
771 }
772 if sig == "defaults,filename_template,properties,required,type" {
774 return format_type_show_text(map);
775 }
776 if map.contains_key("total")
778 && map.contains_key("files")
779 && let Some(serde_json::Value::Array(arr)) = map.get("files")
780 {
781 let is_lint = arr
782 .first()
783 .and_then(|v| v.as_object())
784 .is_some_and(|m| m.contains_key("file") && m.contains_key("violations"))
785 || arr.is_empty();
786 if is_lint {
787 return format_lint_output_text(map);
788 }
789 }
790 if map.contains_key("file") && map.contains_key("modified") {
792 let filter = build_file_object_filter(map);
793 if let Some(output) = apply_jq_filter(&filter, value, cache) {
794 return output;
795 }
796 }
797 format_object_generic(map, cache)
799 }
800 other => format_scalar(other, cache),
801 }
802}
803
804fn format_lint_output_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
808 use std::fmt::Write as _;
809
810 let mut s = String::new();
811 let dry_run = map
812 .get("dry_run")
813 .and_then(serde_json::Value::as_bool)
814 .unwrap_or(false);
815
816 if let Some(fixes_arr) = map.get("fixes").and_then(|f| f.as_array()) {
818 let verb = if dry_run { "Would fix" } else { "Fixed" };
819 for file_fix in fixes_arr {
820 let file = file_fix
821 .get("file")
822 .and_then(serde_json::Value::as_str)
823 .unwrap_or("?");
824 let actions = file_fix.get("actions").and_then(|a| a.as_array());
825 let Some(actions) = actions else { continue };
826 if actions.is_empty() {
827 continue;
828 }
829 let _ = writeln!(s, "{verb} {file}:");
830 for a in actions {
831 let kind = a
832 .get("kind")
833 .and_then(serde_json::Value::as_str)
834 .unwrap_or("");
835 let property = a
836 .get("property")
837 .and_then(serde_json::Value::as_str)
838 .unwrap_or("?");
839 let new = a
840 .get("new")
841 .and_then(serde_json::Value::as_str)
842 .unwrap_or("");
843 let old = a.get("old").and_then(serde_json::Value::as_str);
844 match (kind, old) {
845 ("insert-default", _) => {
846 let _ = writeln!(s, " insert {property} = {new:?}");
847 }
848 ("infer-type", _) => {
849 let _ = writeln!(s, " infer type = {new:?}");
850 }
851 ("fix-enum-typo", Some(old_v)) => {
852 let _ = writeln!(s, " enum {property}: {old_v:?} -> {new:?}");
853 }
854 ("normalize-date", Some(old_v)) => {
855 let _ = writeln!(s, " date {property}: {old_v:?} -> {new:?}");
856 }
857 _ => {
858 let _ = writeln!(s, " {kind} {property} = {new:?}");
859 }
860 }
861 }
862 }
863 }
864
865 let files = map.get("files").and_then(|f| f.as_array());
867 if let Some(files) = files {
868 for file_entry in files {
869 let file = file_entry
870 .get("file")
871 .and_then(serde_json::Value::as_str)
872 .unwrap_or("?");
873 let violations = file_entry.get("violations").and_then(|v| v.as_array());
874 let Some(violations) = violations else {
875 continue;
876 };
877 if violations.is_empty() {
878 continue;
879 }
880 let _ = writeln!(s, "{file}:");
881 for v in violations {
882 let severity = v
883 .get("severity")
884 .and_then(serde_json::Value::as_str)
885 .unwrap_or("warn");
886 let message = v
887 .get("message")
888 .and_then(serde_json::Value::as_str)
889 .unwrap_or("");
890 let pad = if severity == "error" {
891 "error"
892 } else {
893 "warn "
894 };
895 let _ = writeln!(s, " {pad} {message}");
896 }
897 }
898 }
899
900 let error_count: u64 = map
901 .get("errors")
902 .and_then(serde_json::Value::as_u64)
903 .unwrap_or(0);
904 let warn_count: u64 = map
905 .get("warnings")
906 .and_then(serde_json::Value::as_u64)
907 .unwrap_or(0);
908 let files_with_issues: u64 = map
909 .get("files_with_issues")
910 .and_then(serde_json::Value::as_u64)
911 .unwrap_or(0);
912 let limited = map
913 .get("limited")
914 .and_then(serde_json::Value::as_bool)
915 .unwrap_or(false);
916 let shown_files = map
917 .get("files")
918 .and_then(|f| f.as_array())
919 .map_or(0, |arr| {
920 arr.iter()
921 .filter(|e| {
922 e.get("violations")
923 .and_then(|v| v.as_array())
924 .is_some_and(|v| !v.is_empty())
925 })
926 .count()
927 });
928
929 if limited {
930 let _ = writeln!(
931 s,
932 "… (showing {shown_files} of {files_with_issues} files with issues)"
933 );
934 }
935
936 let files_checked: u64 = map
938 .get("files_checked")
939 .and_then(serde_json::Value::as_u64)
940 .unwrap_or(0);
941 let files_label = if files_checked == 1 { "file" } else { "files" };
942 if error_count == 0 && warn_count == 0 {
943 let _ = write!(s, "{files_checked} {files_label} checked, no issues");
944 } else {
945 let _ = write!(
946 s,
947 "{files_checked} {files_label} checked, {files_with_issues} with issues ({error_count} errors, {warn_count} warnings)",
948 );
949 }
950
951 let fix_count: usize = map
952 .get("fixes")
953 .and_then(|f| f.as_array())
954 .map_or(0, |arr| {
955 arr.iter()
956 .filter_map(|f| f.get("actions").and_then(|a| a.as_array()).map(Vec::len))
957 .sum()
958 });
959 if fix_count > 0 {
960 let fixed_label = if dry_run { "would fix" } else { "fixed" };
961 let _ = write!(s, " — {fixed_label} {fix_count}");
962 }
963
964 s
965}
966
967fn format_type_show_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
987 use std::fmt::Write as _;
988
989 let mut s = String::new();
990
991 let type_name = map
992 .get("type")
993 .and_then(serde_json::Value::as_str)
994 .unwrap_or("?");
995 let _ = write!(s, "Type: {type_name}");
996
997 if let Some(serde_json::Value::Array(req)) = map.get("required")
999 && !req.is_empty()
1000 {
1001 let list: Vec<&str> = req.iter().filter_map(serde_json::Value::as_str).collect();
1002 let _ = write!(s, "\n\nRequired: {}", list.join(", "));
1003 }
1004
1005 if let Some(serde_json::Value::Object(defaults)) = map.get("defaults")
1007 && !defaults.is_empty()
1008 {
1009 let _ = write!(s, "\n\nDefaults:");
1010 let mut keys: Vec<&str> = defaults.keys().map(String::as_str).collect();
1011 keys.sort_unstable();
1012 for key in keys {
1013 if let Some(value) = defaults.get(key) {
1014 let display = match value {
1015 serde_json::Value::String(sv) => sv.clone(),
1016 other => other.to_string(),
1017 };
1018 let _ = write!(s, "\n {key}: {display}");
1019 }
1020 }
1021 }
1022
1023 if let Some(serde_json::Value::Object(props)) = map.get("properties")
1025 && !props.is_empty()
1026 {
1027 let _ = write!(s, "\n\nProperties:");
1028 let mut prop_names: Vec<&str> = props.keys().map(String::as_str).collect();
1029 prop_names.sort_unstable();
1030 for name in prop_names {
1031 let Some(prop_val) = props.get(name) else {
1032 continue;
1033 };
1034 let _ = write!(s, "\n {name}:");
1035 if let Some(obj) = prop_val.as_object() {
1036 let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
1039 keys.sort_unstable_by(|a, b| {
1040 if *a == "type" {
1041 std::cmp::Ordering::Less
1042 } else if *b == "type" {
1043 std::cmp::Ordering::Greater
1044 } else {
1045 a.cmp(b)
1046 }
1047 });
1048 for key in keys {
1049 if let Some(v) = obj.get(key) {
1050 let display = match v {
1051 serde_json::Value::Array(arr) => arr
1052 .iter()
1053 .filter_map(serde_json::Value::as_str)
1054 .collect::<Vec<_>>()
1055 .join(", "),
1056 serde_json::Value::String(sv) => sv.clone(),
1057 other => other.to_string(),
1058 };
1059 let _ = write!(s, "\n {key}: {display}");
1060 }
1061 }
1062 }
1063 s.push('\n'); }
1065 }
1066
1067 if let Some(serde_json::Value::String(tmpl)) = map.get("filename_template") {
1069 let _ = write!(s, "\nFilename template: {tmpl}");
1070 }
1071
1072 s
1073}
1074
1075fn format_type_list_entry_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
1087 use std::fmt::Write as _;
1088
1089 let mut s = String::new();
1090
1091 let type_name = map
1092 .get("type")
1093 .and_then(serde_json::Value::as_str)
1094 .unwrap_or("?");
1095
1096 let req_arr: &[serde_json::Value] = map
1097 .get("required")
1098 .and_then(serde_json::Value::as_array)
1099 .map_or(&[], Vec::as_slice);
1100 let req_count = req_arr.len();
1101
1102 let prop_count = map
1103 .get("property_count")
1104 .and_then(serde_json::Value::as_u64)
1105 .unwrap_or(0);
1106
1107 let has_filename = map
1108 .get("has_filename_template")
1109 .and_then(serde_json::Value::as_bool)
1110 .unwrap_or(false);
1111
1112 let prop_label = if prop_count == 1 {
1113 "property"
1114 } else {
1115 "properties"
1116 };
1117 let _ = write!(
1118 s,
1119 "{type_name} ({prop_count} {prop_label}, {req_count} required)"
1120 );
1121
1122 if !req_arr.is_empty() {
1123 let list: Vec<&str> = req_arr
1124 .iter()
1125 .filter_map(serde_json::Value::as_str)
1126 .collect();
1127 let _ = write!(s, "\n required: {}", list.join(", "));
1128 }
1129
1130 if has_filename {
1131 let _ = write!(s, "\n filename: (see type details)");
1132 }
1133
1134 s
1135}
1136
1137fn format_object_generic(
1139 map: &serde_json::Map<String, serde_json::Value>,
1140 cache: &mut JaqFilterCache,
1141) -> String {
1142 map.iter()
1143 .map(|(k, v)| format!("{k}: {}", format_value_as_text(v, cache)))
1144 .collect::<Vec<_>>()
1145 .join("\n")
1146}
1147
1148fn format_scalar(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
1150 match value {
1151 serde_json::Value::String(s) => s.clone(),
1152 serde_json::Value::Number(n) => n.to_string(),
1153 serde_json::Value::Bool(b) => b.to_string(),
1154 serde_json::Value::Null => "null".to_owned(),
1155 serde_json::Value::Array(arr) => {
1156 let items: Vec<String> = arr.iter().map(|v| format_scalar(v, cache)).collect();
1157 items.join(", ")
1158 }
1159 serde_json::Value::Object(_) => format_value_as_text(value, cache),
1160 }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165 use super::*;
1166 use serde_json::json;
1167
1168 fn jq(filter: &str, val: &serde_json::Value) -> Option<String> {
1170 apply_jq_filter(filter, val, &mut JaqFilterCache::new())
1171 }
1172
1173 fn fmt(val: &serde_json::Value) -> String {
1174 format_value_as_text(val, &mut JaqFilterCache::new())
1175 }
1176
1177 fn scalar(val: &serde_json::Value) -> String {
1178 format_scalar(val, &mut JaqFilterCache::new())
1179 }
1180
1181 #[test]
1184 fn format_json_error() {
1185 let out = format_error(
1186 Format::Json,
1187 "file not found",
1188 Some("foo/bar"),
1189 Some("did you mean foo/bar.md?"),
1190 None,
1191 );
1192 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1193 assert_eq!(parsed["error"], "file not found");
1194 assert_eq!(parsed["hint"], "did you mean foo/bar.md?");
1195 assert!(parsed.get("cause").is_none());
1196 }
1197
1198 #[test]
1199 fn format_text_error() {
1200 let out = format_error(Format::Text, "file not found", Some("foo"), None, None);
1201 assert!(out.contains("Error: file not found"));
1202 assert!(out.contains("path: foo"));
1203 }
1204
1205 #[test]
1206 fn format_json_success() {
1207 let val = json!({"name": "test", "value": 42});
1208 let out = format_success(Format::Json, &val);
1209 assert!(out.contains("\"name\": \"test\""));
1210 }
1211
1212 #[test]
1215 fn apply_jq_filter_simple() {
1216 let val = json!({"name": "hello", "count": 3});
1217 let result = jq(r#""\(.name): \(.count)""#, &val);
1218 assert_eq!(result.as_deref(), Some("hello: 3"));
1219 }
1220
1221 #[test]
1222 fn apply_jq_filter_array_map() {
1223 let val = json!(["a", "b", "c"]);
1224 let result = jq(".[]", &val);
1225 assert_eq!(result.as_deref(), Some("a\nb\nc"));
1226 }
1227
1228 #[test]
1229 fn apply_jq_filter_invalid_returns_none() {
1230 let val = json!({"x": 1});
1231 let result = jq("this is not valid jq %%%", &val);
1232 assert!(result.is_none());
1233 }
1234
1235 #[test]
1238 fn jq_output_cap_constant_is_10_mib() {
1239 assert_eq!(JQ_OUTPUT_CAP, 10 * 1024 * 1024);
1240 }
1241
1242 #[test]
1243 fn jq_output_within_cap_succeeds() {
1244 let val = json!({"msg": "hello"});
1246 let result = apply_jq_filter_result(".msg", &val);
1247 assert_eq!(result.as_deref(), Ok("hello"));
1248 }
1249
1250 #[test]
1251 fn jq_output_cap_triggers_on_large_output() {
1252 let big_string = "a".repeat(1000);
1255 let val = serde_json::Value::Array(
1256 std::iter::repeat_n(serde_json::Value::String(big_string), 11_000).collect(),
1257 );
1258 let result = apply_jq_filter_result(".[]", &val);
1260 assert!(result.is_err(), "expected cap error but got Ok output");
1261 let err = result.unwrap_err();
1262 assert!(
1263 err.contains("exceeds") && err.contains("MiB"),
1264 "unexpected error message: {err}"
1265 );
1266 }
1267
1268 #[test]
1271 fn property_info_filter() {
1272 let val = json!({"name": "title", "type": "text", "value": "My Note"});
1273 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
1274 assert!(out.contains("title"));
1275 assert!(out.contains("text"));
1276 assert!(out.contains("My Note"));
1277 }
1278
1279 #[test]
1280 fn property_info_filter_list_value() {
1281 let val = json!({"name": "tags", "type": "list", "value": ["rust", "cli"]});
1282 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
1283 assert!(out.contains("tags"));
1284 assert!(out.contains("list"));
1285 assert!(out.contains("[rust, cli]"), "expected [rust, cli]: {out}");
1287 assert!(!out.contains("[\"rust\""));
1288 }
1289
1290 #[test]
1291 fn property_summary_entry_filter() {
1292 let val = json!({"count": 7, "name": "title", "type": "text"});
1293 let out = jq(PROPERTY_SUMMARY_ENTRY_FILTER, &val).unwrap();
1294 assert!(out.contains("title"));
1295 assert!(out.contains("text"));
1296 assert!(out.contains("7 files"));
1297 }
1298
1299 #[test]
1300 fn tag_summary_filter() {
1301 let val = json!({
1302 "tags": [{"name": "rust", "count": 3}, {"name": "cli", "count": 1}],
1303 "total": 2
1304 });
1305 let out = jq(TAG_SUMMARY_FILTER, &val).unwrap();
1306 assert!(out.contains("2 unique tags"));
1307 assert!(out.contains("rust"));
1308 assert!(out.contains("3 files"));
1309 }
1310
1311 #[test]
1314 fn link_info_target_only_filter() {
1315 let val = json!({"target": "broken-link"});
1316 let out = jq(LINK_INFO_TARGET_FILTER, &val).unwrap();
1317 assert!(out.contains("broken-link"));
1318 assert!(out.contains("unresolved"));
1319 }
1320
1321 #[test]
1322 fn link_info_with_path_filter() {
1323 let val = json!({"path": "note-b.md", "target": "note-b"});
1324 let out = jq(LINK_INFO_PATH_FILTER, &val).unwrap();
1325 assert!(out.contains("note-b"));
1326 assert!(out.contains("note-b.md"));
1327 }
1328
1329 #[test]
1332 fn task_count_filter() {
1333 let val = json!({"done": 3, "total": 5});
1334 let out = jq(TASK_COUNT_FILTER, &val).unwrap();
1335 assert_eq!(out, "[3/5]");
1336 }
1337
1338 #[test]
1339 fn outline_section_filter() {
1340 let val = json!({
1341 "code_blocks": [],
1342 "heading": "Introduction",
1343 "level": 1,
1344 "line": 5,
1345 "links": ["[[other]]"]
1346 });
1347 let out = jq(OUTLINE_SECTION_FILTER, &val).unwrap();
1348 assert!(out.contains('#'));
1349 assert!(out.contains("Introduction"));
1350 assert!(out.contains("[[other]]"));
1351 }
1352
1353 #[test]
1354 fn outline_section_with_tasks_filter() {
1355 let val = json!({
1356 "code_blocks": [],
1357 "heading": "Tasks",
1358 "level": 2,
1359 "line": 10,
1360 "links": [],
1361 "tasks": {"done": 2, "total": 4}
1362 });
1363 let out = jq(OUTLINE_SECTION_WITH_TASKS_FILTER, &val).unwrap();
1364 assert!(out.contains("##"));
1365 assert!(out.contains("Tasks"));
1366 assert!(out.contains("[2/4]"));
1367 }
1368
1369 #[test]
1372 fn find_task_info_filter_done() {
1373 let val = json!({
1374 "done": true,
1375 "line": 42,
1376 "section": "Implementation",
1377 "status": "x",
1378 "text": "Write the tests"
1379 });
1380 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
1381 assert!(out.contains("[x]"));
1382 assert!(out.contains("Write the tests"));
1383 assert!(out.contains("line 42"));
1384 assert!(out.contains("Implementation"));
1385 }
1386
1387 #[test]
1388 fn find_task_info_filter_not_done() {
1389 let val = json!({
1390 "done": false,
1391 "line": 7,
1392 "section": "Todo",
1393 "status": " ",
1394 "text": "Review PR"
1395 });
1396 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
1397 assert!(out.contains("[ ]"));
1398 assert!(out.contains("Review PR"));
1399 assert!(out.contains("line 7"));
1400 assert!(out.contains("Todo"));
1401 }
1402
1403 #[test]
1404 fn find_task_info_via_format_value_as_text() {
1405 let val = json!({
1407 "done": true,
1408 "line": 5,
1409 "section": "Goals",
1410 "status": "x",
1411 "text": "Ship it"
1412 });
1413 let out = fmt(&val);
1414 assert!(out.contains("[x]"));
1415 assert!(out.contains("Ship it"));
1416 assert!(
1417 !out.contains("done: true"),
1418 "should not use generic fallback"
1419 );
1420 }
1421
1422 #[test]
1425 fn content_match_filter() {
1426 let val = json!({
1427 "line": 15,
1428 "section": "Background",
1429 "text": "This is the matching line"
1430 });
1431 let out = jq(CONTENT_MATCH_FILTER, &val).unwrap();
1432 assert!(out.contains("line 15"));
1433 assert!(out.contains("Background"));
1434 assert!(out.contains("This is the matching line"));
1435 }
1436
1437 #[test]
1438 fn content_match_via_format_value_as_text() {
1439 let val = json!({
1440 "line": 3,
1441 "section": "Intro",
1442 "text": "hello world"
1443 });
1444 let out = fmt(&val);
1445 assert!(out.contains("line 3"));
1446 assert!(out.contains("hello world"));
1447 assert!(!out.contains("line: 3"), "should not use generic fallback");
1448 }
1449
1450 #[test]
1453 fn property_value_mutation_filter_with_modified() {
1454 let val = json!({
1457 "modified": ["note-a.md", "note-b.md"],
1458 "property": "status",
1459 "scanned": 2,
1460 "skipped": [],
1461 "total": 2,
1462 "value": "done"
1463 });
1464 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1465 assert!(out.contains("status=done"));
1466 assert!(out.contains("2/2 modified"));
1467 assert!(
1468 !out.contains("scanned"),
1469 "no scanned suffix when scanned == total"
1470 );
1471 assert!(out.contains("note-a.md"));
1472 assert!(out.contains("note-b.md"));
1473 }
1474
1475 #[test]
1476 fn property_value_mutation_filter_all_skipped() {
1477 let val = json!({
1478 "modified": [],
1479 "property": "priority",
1480 "scanned": 1,
1481 "skipped": ["note-a.md"],
1482 "total": 1,
1483 "value": "high"
1484 });
1485 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1486 assert!(out.contains("priority=high"));
1487 assert!(out.contains("0/1 modified"));
1488 assert!(!out.contains("note-a.md"));
1490 }
1491
1492 #[test]
1493 fn property_value_mutation_filter_with_where_filter() {
1494 let val = json!({
1496 "modified": ["note-a.md"],
1497 "property": "status",
1498 "scanned": 5,
1499 "skipped": [],
1500 "total": 1,
1501 "value": "done"
1502 });
1503 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1504 assert!(out.contains("status=done"));
1505 assert!(out.contains("1/1 modified"));
1506 assert!(out.contains("(5 scanned)"));
1507 }
1508
1509 #[test]
1510 fn property_value_mutation_via_format_value_as_text() {
1511 let val = json!({
1512 "dry_run": false,
1513 "modified": ["notes/a.md"],
1514 "property": "status",
1515 "scanned": 1,
1516 "skipped": [],
1517 "total": 1,
1518 "value": "done"
1519 });
1520 let out = fmt(&val);
1521 assert!(out.contains("status=done"));
1522 assert!(
1523 !out.contains("modified: "),
1524 "should not use generic fallback"
1525 );
1526 }
1527
1528 #[test]
1529 fn property_mutation_filter_no_value() {
1530 let val = json!({
1532 "dry_run": false,
1533 "modified": ["note.md"],
1534 "property": "draft",
1535 "scanned": 1,
1536 "skipped": [],
1537 "total": 1
1538 });
1539 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1540 assert!(out.contains("draft"));
1541 assert!(out.contains("1/1 modified"));
1542 assert!(
1543 !out.contains("scanned"),
1544 "no scanned suffix when scanned == total"
1545 );
1546 assert!(out.contains("note.md"));
1547 }
1548
1549 #[test]
1550 fn property_mutation_filter_no_value_with_where_filter() {
1551 let val = json!({
1553 "dry_run": false,
1554 "modified": ["note.md"],
1555 "property": "draft",
1556 "scanned": 7,
1557 "skipped": [],
1558 "total": 1
1559 });
1560 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1561 assert!(out.contains("draft"));
1562 assert!(out.contains("1/1 modified"));
1563 assert!(out.contains("(7 scanned)"));
1564 }
1565
1566 #[test]
1567 fn tag_mutation_filter_with_modified() {
1568 let val = json!({
1570 "dry_run": false,
1571 "modified": ["a.md", "b.md"],
1572 "scanned": 3,
1573 "skipped": ["c.md"],
1574 "tag": "rust",
1575 "total": 3
1576 });
1577 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1578 assert!(out.contains("rust"));
1579 assert!(out.contains("2/3 modified"));
1580 assert!(
1581 !out.contains("scanned"),
1582 "no scanned suffix when scanned == total"
1583 );
1584 assert!(out.contains("a.md"));
1585 assert!(out.contains("b.md"));
1586 assert!(!out.contains("c.md"));
1587 }
1588
1589 #[test]
1590 fn tag_mutation_filter_with_where_filter() {
1591 let val = json!({
1593 "dry_run": false,
1594 "modified": ["a.md"],
1595 "scanned": 10,
1596 "skipped": [],
1597 "tag": "rust",
1598 "total": 1
1599 });
1600 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1601 assert!(out.contains("rust"));
1602 assert!(out.contains("1/1 modified"));
1603 assert!(out.contains("(10 scanned)"));
1604 }
1605
1606 #[test]
1607 fn tag_mutation_via_format_value_as_text() {
1608 let val = json!({
1609 "dry_run": false,
1610 "modified": [],
1611 "scanned": 1,
1612 "skipped": ["note.md"],
1613 "tag": "cli",
1614 "total": 1
1615 });
1616 let out = fmt(&val);
1617 assert!(out.contains("cli"));
1618 assert!(!out.contains("tag: cli"), "should not use generic fallback");
1619 }
1620
1621 #[test]
1624 fn property_value_mutation_dry_run_prefix() {
1625 let val = json!({
1626 "dry_run": true,
1627 "modified": ["note.md"],
1628 "property": "status",
1629 "scanned": 1,
1630 "skipped": [],
1631 "total": 1,
1632 "value": "done"
1633 });
1634 let out = fmt(&val);
1635 assert!(
1636 out.contains("[dry-run] status=done"),
1637 "dry-run prefix missing: {out}"
1638 );
1639 }
1640
1641 #[test]
1642 fn tag_mutation_dry_run_prefix() {
1643 let val = json!({
1644 "dry_run": true,
1645 "modified": ["note.md"],
1646 "scanned": 1,
1647 "skipped": [],
1648 "tag": "rust",
1649 "total": 1
1650 });
1651 let out = fmt(&val);
1652 assert!(
1653 out.contains("[dry-run] rust"),
1654 "dry-run prefix missing: {out}"
1655 );
1656 }
1657
1658 #[test]
1659 fn property_value_mutation_no_dry_run_prefix() {
1660 let val = json!({
1661 "dry_run": false,
1662 "modified": ["note.md"],
1663 "property": "status",
1664 "scanned": 1,
1665 "skipped": [],
1666 "total": 1,
1667 "value": "done"
1668 });
1669 let out = fmt(&val);
1670 assert!(
1671 !out.contains("[dry-run]"),
1672 "should not have dry-run prefix: {out}"
1673 );
1674 }
1675
1676 #[test]
1679 fn build_file_object_filter_minimal() {
1680 let map: serde_json::Map<String, serde_json::Value> =
1682 serde_json::from_str(r#"{"file": "notes/foo.md", "modified": "2024-01-01"}"#).unwrap();
1683 let filter = build_file_object_filter(&map);
1684 let val = json!({"file": "notes/foo.md", "modified": "2024-01-01"});
1685 let out = jq(&filter, &val).unwrap();
1686 assert!(out.contains("notes/foo.md"));
1687 assert!(out.contains("2024-01-01"));
1688 }
1689
1690 #[test]
1691 fn build_file_object_filter_with_tags() {
1692 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1693 r#"{"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]}"#,
1694 )
1695 .unwrap();
1696 let filter = build_file_object_filter(&map);
1697 let val = json!({"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]});
1698 let out = jq(&filter, &val).unwrap();
1699 assert!(out.contains("foo.md"));
1700 assert!(out.contains("tags: [rust, cli]"));
1701 }
1702
1703 #[test]
1704 fn build_file_object_filter_with_properties() {
1705 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1706 r#"{"file": "foo.md", "modified": "2024-01-01", "properties": {"status": "done"}}"#,
1707 )
1708 .unwrap();
1709 let filter = build_file_object_filter(&map);
1710 let val = json!({
1711 "file": "foo.md",
1712 "modified": "2024-01-01",
1713 "properties": {"status": "done"}
1714 });
1715 let out = jq(&filter, &val).unwrap();
1716 assert!(out.contains("foo.md"));
1717 assert!(out.contains("properties:"));
1718 assert!(out.contains("status: done"));
1719 }
1720
1721 #[test]
1722 fn build_file_object_filter_with_tasks() {
1723 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1724 r#"{"file": "foo.md", "modified": "2024-01-01", "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]}"#,
1725 )
1726 .unwrap();
1727 let filter = build_file_object_filter(&map);
1728 let val = json!({
1729 "file": "foo.md",
1730 "modified": "2024-01-01",
1731 "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]
1732 });
1733 let out = jq(&filter, &val).unwrap();
1734 assert!(out.contains("foo.md"));
1735 assert!(out.contains("tasks:"));
1736 assert!(out.contains("[x] Ship it"));
1737 assert!(out.contains("line 5"));
1738 }
1739
1740 #[test]
1741 fn build_file_object_filter_with_sections() {
1742 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1743 r#"{"file": "foo.md", "modified": "2024-01-01", "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]}"#,
1744 )
1745 .unwrap();
1746 let filter = build_file_object_filter(&map);
1747 let val = json!({
1748 "file": "foo.md",
1749 "modified": "2024-01-01",
1750 "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]
1751 });
1752 let out = jq(&filter, &val).unwrap();
1753 assert!(out.contains("foo.md"));
1754 assert!(out.contains("sections:"));
1755 assert!(out.contains("# Intro"));
1756 }
1757
1758 #[test]
1759 fn build_file_object_filter_with_matches() {
1760 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1761 r#"{"file": "foo.md", "modified": "2024-01-01", "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]}"#,
1762 )
1763 .unwrap();
1764 let filter = build_file_object_filter(&map);
1765 let val = json!({
1766 "file": "foo.md",
1767 "modified": "2024-01-01",
1768 "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]
1769 });
1770 let out = jq(&filter, &val).unwrap();
1771 assert!(out.contains("foo.md"));
1772 assert!(out.contains("matches:"));
1773 assert!(out.contains("line 3 (Intro): hello world"));
1774 }
1775
1776 #[test]
1777 fn build_file_object_filter_with_links() {
1778 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1779 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "bar", "path": "bar.md"}]}"#,
1780 )
1781 .unwrap();
1782 let filter = build_file_object_filter(&map);
1783 let val = json!({
1784 "file": "foo.md",
1785 "modified": "2024-01-01",
1786 "links": [{"target": "bar", "path": "bar.md"}]
1787 });
1788 let out = jq(&filter, &val).unwrap();
1789 assert!(out.contains("foo.md"));
1790 assert!(out.contains("links:"));
1791 assert!(out.contains(r#""bar" → "bar.md""#));
1792 }
1793
1794 #[test]
1795 fn build_file_object_filter_unresolved_link() {
1796 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1797 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "missing"}]}"#,
1798 )
1799 .unwrap();
1800 let filter = build_file_object_filter(&map);
1801 let val = json!({
1802 "file": "foo.md",
1803 "modified": "2024-01-01",
1804 "links": [{"target": "missing"}]
1805 });
1806 let out = jq(&filter, &val).unwrap();
1807 assert!(out.contains(r#""missing" (unresolved)"#));
1808 }
1809
1810 #[test]
1813 fn file_object_text_rendering_minimal() {
1814 let val = json!({"file": "notes/foo.md", "modified": "2024-01-15"});
1815 let out = fmt(&val);
1816 assert!(out.contains("notes/foo.md"));
1817 assert!(out.contains("2024-01-15"));
1818 assert!(!out.contains("file: notes/foo.md"));
1820 }
1821
1822 #[test]
1823 fn file_object_text_rendering_full() {
1824 let val = json!({
1825 "file": "notes/project.md",
1826 "modified": "2024-03-01",
1827 "tags": ["rust", "work"],
1828 "properties": {"status": "active"},
1829 "tasks": [
1830 {"done": false, "line": 10, "section": "Todo", "status": " ", "text": "Fix bug"},
1831 {"done": true, "line": 20, "section": "Done", "status": "x", "text": "Write docs"}
1832 ]
1833 });
1834 let out = fmt(&val);
1835 assert!(out.contains("notes/project.md"));
1836 assert!(out.contains("properties:"));
1837 assert!(out.contains("status: active"));
1838 assert!(out.contains("tags: [rust, work]"));
1839 assert!(out.contains("tasks:"));
1840 assert!(out.contains("[ ] Fix bug"));
1841 assert!(out.contains("[x] Write docs"));
1842 }
1843
1844 #[test]
1847 fn array_of_file_objects_uses_blank_line_separator() {
1848 let val = json!([
1849 {"file": "a.md", "modified": "2024-01-01"},
1850 {"file": "b.md", "modified": "2024-01-02"}
1851 ]);
1852 let out = fmt(&val);
1853 assert!(out.contains("a.md"));
1854 assert!(out.contains("b.md"));
1855 assert!(
1857 out.contains("\n\n"),
1858 "expected blank-line separator between file objects"
1859 );
1860 }
1861
1862 #[test]
1863 fn array_of_non_file_objects_uses_single_newline() {
1864 let val = json!([
1865 {"count": 1, "name": "status", "type": "text"},
1866 {"count": 3, "name": "title", "type": "text"}
1867 ]);
1868 let out = fmt(&val);
1869 assert!(out.contains("status"));
1870 assert!(out.contains("title"));
1871 assert!(
1873 !out.contains("\n\n"),
1874 "non-file-objects should use single newline"
1875 );
1876 }
1877
1878 #[test]
1881 fn format_scalar_delegates_nested_objects() {
1882 let inner = json!({"count": 2, "name": "status", "type": "text"});
1885 let out = scalar(&inner);
1886 assert!(
1888 !out.contains("count=2"),
1889 "should delegate to format_value_as_text"
1890 );
1891 assert!(out.contains("status"));
1893 assert!(out.contains("2 files"));
1894 }
1895
1896 #[test]
1899 fn format_value_as_text_uses_filter_for_known_shape() {
1900 let val = json!({"count": 3, "name": "status", "type": "text"});
1902 let out = fmt(&val);
1903 assert!(out.contains("status"));
1904 assert!(out.contains("3 files"));
1905 assert!(!out.contains("count: 3"));
1907 }
1908
1909 #[test]
1910 fn format_value_as_text_falls_back_for_unknown_shape() {
1911 let val = json!({"foo": "bar", "baz": 42});
1912 let out = fmt(&val);
1913 assert!(out.contains("foo: bar") || out.contains("baz: 42"));
1915 }
1916
1917 #[test]
1918 fn mv_result_filter_applied() {
1919 let val = json!({
1920 "dry_run": false,
1921 "from": "sub/b.md",
1922 "to": "archive/b.md",
1923 "total_files_updated": 1,
1924 "total_links_updated": 1,
1925 "updated_files": [
1926 {
1927 "file": "a.md",
1928 "replacements": [
1929 {"old_text": "[[sub/b]]", "new_text": "[[archive/b]]", "line": 1}
1930 ]
1931 }
1932 ]
1933 });
1934 let sig = {
1936 let map = val.as_object().unwrap();
1937 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
1938 keys.sort_unstable();
1939 keys.join(",")
1940 };
1941 assert_eq!(
1942 sig,
1943 "dry_run,from,to,total_files_updated,total_links_updated,updated_files"
1944 );
1945 let filter_result = apply_jq_filter_result(MV_RESULT_FILTER, &val);
1947 assert!(filter_result.is_ok(), "filter error: {filter_result:?}");
1948 let out = filter_result.unwrap();
1949 assert!(out.contains("Moved sub/b.md"), "out: {out}");
1950 assert!(out.contains("archive/b.md"), "out: {out}");
1951 assert!(out.contains("[[sub/b]]"), "out: {out}");
1952 assert!(out.contains("[[archive/b]]"), "out: {out}");
1953 let found_filter =
1955 lookup_filter("dry_run,from,to,total_files_updated,total_links_updated,updated_files");
1956 assert!(
1957 found_filter.is_some(),
1958 "lookup_filter returned None for MvResult shape"
1959 );
1960 let formatted = fmt(&val);
1962 assert!(
1963 formatted.contains("Moved sub/b.md"),
1964 "formatted: {formatted}"
1965 );
1966 }
1967
1968 #[test]
1969 fn format_value_as_text_array_of_typed_objects() {
1970 let val = json!([
1971 {"path": "a.md", "tags": ["rust"]},
1972 {"path": "b.md", "tags": ["cli"]}
1973 ]);
1974 let out = fmt(&val);
1975 assert!(out.contains("a.md"));
1976 assert!(out.contains("b.md"));
1977 assert!(out.contains("rust"));
1978 assert!(out.contains("cli"));
1979 }
1980
1981 #[test]
1984 fn sanitize_control_chars_strips_escape_sequences() {
1985 let input = "Hello\x1b[31mRED\x1b[0m World";
1986 let output = sanitize_control_chars(input);
1987 assert!(
1988 !output.contains('\x1b'),
1989 "escape sequences should be stripped"
1990 );
1991 assert!(output.contains("Hello"));
1992 assert!(output.contains("RED"));
1993 assert!(output.contains("World"));
1994 }
1995
1996 #[test]
1997 fn sanitize_control_chars_preserves_newline_and_tab() {
1998 let input = "line1\nline2\ttabbed";
1999 let output = sanitize_control_chars(input);
2000 assert_eq!(output, input);
2001 }
2002
2003 #[test]
2004 fn text_output_sanitizes_escape_sequences() {
2005 let value = serde_json::json!({
2006 "results": {
2007 "title": "Hello\x1b[31mRED\x1b[0m World",
2008 "file": "test\x1b[2J.md"
2009 }
2010 });
2011 let output = format_success(Format::Text, &value);
2012 assert!(
2013 !output.contains('\x1b'),
2014 "escape sequences should be stripped"
2015 );
2016 assert!(output.contains("Hello") && output.contains("World"));
2017 }
2018}