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
112#[must_use]
114pub fn format_success(format: Format, value: &serde_json::Value) -> String {
115 match format {
116 Format::Json => serde_json::to_string_pretty(value)
117 .expect("serializing serde_json::Value is infallible"),
118 Format::Text => {
119 let mut cache = JaqFilterCache::new();
120 format_value_as_text(value, &mut cache)
121 }
122 }
123}
124
125#[must_use]
130pub fn format_output<T: Serialize>(format: Format, value: &T) -> String {
131 let json = serde_json::to_value(value).expect("derived Serialize impl should not fail");
132 format_success(format, &json)
133}
134
135#[must_use]
140pub fn build_envelope_value(
141 value: &serde_json::Value,
142 total: Option<u64>,
143 hints: &[crate::hints::Hint],
144) -> serde_json::Value {
145 let hints_json: Vec<serde_json::Value> = hints
146 .iter()
147 .map(|h| serde_json::json!({"description": &h.description, "cmd": &h.cmd}))
148 .collect();
149 let mut envelope = serde_json::json!({
150 "results": value,
151 "hints": hints_json,
152 });
153 if let Some(t) = total {
154 envelope["total"] = serde_json::json!(t);
155 }
156 envelope
157}
158
159#[must_use]
164pub fn format_envelope(
165 format: Format,
166 value: &serde_json::Value,
167 total: Option<u64>,
168 hints: &[crate::hints::Hint],
169) -> String {
170 match format {
171 Format::Json => {
172 let envelope = build_envelope_value(value, total, hints);
173 serde_json::to_string_pretty(&envelope)
174 .expect("serializing serde_json::Value is infallible")
175 }
176 Format::Text => {
177 let mut cache = JaqFilterCache::new();
178 let mut text = format_results_as_text(value, total, &mut cache);
179 if !hints.is_empty() {
180 text.push('\n');
181 for hint in hints {
182 text.push_str("\n -> ");
183 text.push_str(&hint.cmd);
184 text.push_str(" # ");
185 text.push_str(&hint.description);
186 }
187 }
188 text
189 }
190 }
191}
192
193fn format_results_as_text(
198 results: &serde_json::Value,
199 total: Option<u64>,
200 cache: &mut JaqFilterCache,
201) -> String {
202 if let (Some(total), serde_json::Value::Array(arr)) = (total, results) {
205 let is_tag_array = !arr.is_empty()
206 && arr.iter().all(|v| {
207 v.as_object().is_some_and(|m| {
208 m.contains_key("count") && m.contains_key("name") && m.len() == 2
209 })
210 });
211 if is_tag_array {
212 let tag_label = if total == 1 { "tag" } else { "tags" };
213 let header = format!("{total} unique {tag_label}");
214 let entries = format_value_as_text(results, cache);
215 return if entries.is_empty() {
216 header
217 } else {
218 format!("{header}\n{entries}")
219 };
220 }
221 }
222
223 let text = format_value_as_text(results, cache);
224 if let Some(total) = total {
225 let shown = match results {
226 serde_json::Value::Array(arr) => arr.len() as u64,
227 _ => return text,
228 };
229 if shown < total {
230 return format!("{text}\nshowing {shown} of {total} matches");
231 }
232 }
233 text
234}
235
236#[must_use]
238pub fn format_error(
239 format: Format,
240 error: &str,
241 path: Option<&str>,
242 hint: Option<&str>,
243 cause: Option<&str>,
244) -> String {
245 match format {
246 Format::Json => {
247 let mut obj = json!({"error": error});
248 if let Some(p) = path {
249 obj["path"] = json!(p);
250 }
251 if let Some(h) = hint {
252 obj["hint"] = json!(h);
253 }
254 if let Some(c) = cause {
255 obj["cause"] = json!(c);
256 }
257 serde_json::to_string_pretty(&obj).expect("serializing serde_json::Value is infallible")
258 }
259 Format::Text => {
260 let mut msg = format!("Error: {error}");
261 if let Some(p) = path {
262 let _ = write!(msg, "\n path: {p}");
263 }
264 if let Some(h) = hint {
265 let _ = write!(msg, "\n hint: {h}");
266 }
267 if let Some(c) = cause {
268 let _ = write!(msg, "\n cause: {c}");
269 }
270 msg
271 }
272 }
273}
274
275const PROPERTY_INFO_FILTER: &str = r#""\(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | join(", ")) + "]" else .value end)""#;
282
283const PROPERTY_SUMMARY_ENTRY_FILTER: &str =
285 r#""\(.name)\t\(.type)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;
286
287const TAG_SUMMARY_FILTER: &str = r#""\(.total) unique \(if .total == 1 then "tag" else "tags" end)\n\(.tags | map(" \(.name)\t\(.count) \(if .count == 1 then "file" else "files" end)") | join("\n"))""#;
289
290const TAG_SUMMARY_ENTRY_FILTER: &str =
292 r#""\(.name)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;
293
294const LINK_INFO_TARGET_FILTER: &str = r#"" \"\(.target)\" (unresolved)""#;
297
298const LINK_INFO_PATH_FILTER: &str = r#"" \"\(.target)\" → \"\(.path)\"""#;
301
302const LINK_INFO_LABEL_FILTER: &str = r#"" \"\(.target)\" (unresolved) [\(.label)]""#;
305
306const LINK_INFO_FULL_FILTER: &str = r#"" \"\(.target)\" → \"\(.path)\" [\(.label)]""#;
309
310const TASK_COUNT_FILTER: &str = r#""[\(.done)/\(.total)]""#;
312
313const OUTLINE_SECTION_FILTER: &str = r##""\("#" * .level) \(.heading // "(pre-heading)")\(if (.links | length) > 0 then "\n\(.links | map(" → \"\(.)\"") | join("\n"))" else "" end)""##;
315
316const OUTLINE_SECTION_WITH_TASKS_FILTER: &str = r##""\("#" * .level) \(.heading // "(pre-heading)") [\(.tasks.done)/\(.tasks.total)]\(if (.links | length) > 0 then "\n\(.links | map(" → \"\(.)\"") | join("\n"))" else "" end)""##;
318
319const TASK_INFO_FILTER: &str =
321 r#""line \(.line): [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;
322
323const TASK_READ_RESULT_FILTER: &str =
325 r#""\"\(.file)\":\(.line) [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;
326
327const 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)""#;
330
331const FIND_TASK_INFO_FILTER: &str =
334 r#"" [\(if .done then "x" else " " end)] \(.text) (line \(.line), \(.section))""#;
335
336const CONTENT_MATCH_FILTER: &str = r#"" line \(.line) (\(.section)): \(.text)""#;
339
340const 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)""#;
346
347const 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)""#;
353
354const 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)""#;
360
361const BACKLINKS_RESULT_FILTER: &str = r#"if (.backlinks | length) == 0 then "No backlinks found for \"\(.file)\"" else "\(.backlinks | length) \(if (.backlinks | length) == 1 then "backlink" else "backlinks" end) for \"\(.file)\"\n\(.backlinks | map(" \(.source): line \(.line)") | join("\n"))" end"#;
365
366const LINKS_FIX_FILTER: &str = r#""Broken links: \(.broken)\nFixable: \(.fixable)\nUnfixable: \(.unfixable)\nIgnored: \(.ignored)\nApplied: \(if .applied then "yes" else "no" end)\(if (.fixes | length) > 0 then "\n\(.fixes | map(" \(.source) line \(.line): \"\(.old_target)\" → \"\(.new_target)\"") | join("\n"))" else "" end)""#;
369
370const 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)""#;
373
374const VIEWS_LIST_ENTRY_FILTER: &str = r#""\(.name)\t\(.filters | to_entries | map("\(.key)=\(.value | if type == "array" then join(",") else tostring end)") | join(" "))""#;
377
378const VIEWS_MUTATION_RESULT_FILTER: &str = r#""\(.action): \(.name)""#;
381
382fn key_signature(map: &serde_json::Map<String, serde_json::Value>) -> String {
388 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
389 keys.sort_unstable();
390 keys.join(",")
391}
392
393fn lookup_filter(key_sig: &str) -> Option<&'static str> {
397 match key_sig {
398 "name,type,value" => Some(PROPERTY_INFO_FILTER),
400 "count,name,type" => Some(PROPERTY_SUMMARY_ENTRY_FILTER),
402 "tags,total" => Some(TAG_SUMMARY_FILTER),
404 "count,name" => Some(TAG_SUMMARY_ENTRY_FILTER),
406 "target" => Some(LINK_INFO_TARGET_FILTER),
408 "path,target" => Some(LINK_INFO_PATH_FILTER),
409 "label,target" => Some(LINK_INFO_LABEL_FILTER),
410 "label,path,target" => Some(LINK_INFO_FULL_FILTER),
411 "done,total" => Some(TASK_COUNT_FILTER),
413 "code_blocks,heading,level,line,links" => Some(OUTLINE_SECTION_FILTER),
415 "code_blocks,heading,level,line,links,tasks" => Some(OUTLINE_SECTION_WITH_TASKS_FILTER),
416 "done,line,status,text" => Some(TASK_INFO_FILTER),
418 "done,line,section,status,text" => Some(FIND_TASK_INFO_FILTER),
420 "line,section,text" => Some(CONTENT_MATCH_FILTER),
422 "done,file,line,status,text" => Some(TASK_READ_RESULT_FILTER),
424 "dead_ends,files,links,orphans,properties,recent_files,status,tags,tasks"
426 | "dead_ends,files,links,orphans,properties,recent_files,schema,status,tags,tasks" => {
427 Some(VAULT_SUMMARY_FILTER)
428 }
429 "dry_run,modified,property,scanned,skipped,total,value" => {
432 Some(PROPERTY_VALUE_MUTATION_FILTER)
433 }
434 "dry_run,modified,property,scanned,skipped,total" => Some(PROPERTY_MUTATION_FILTER),
436 "dry_run,modified,scanned,skipped,tag,total" => Some(TAG_MUTATION_FILTER),
438 "backlinks,file" => Some(BACKLINKS_RESULT_FILTER),
440 "applied,broken,fixable,fixes,ignored,unfixable,unfixable_links" => Some(LINKS_FIX_FILTER),
442 "dry_run,from,to,total_files_updated,total_links_updated,updated_files" => {
444 Some(MV_RESULT_FILTER)
445 }
446 "filters,name" => Some(VIEWS_LIST_ENTRY_FILTER),
448 "action,name" => Some(VIEWS_MUTATION_RESULT_FILTER),
450 _ => None,
451 }
452}
453
454fn apply_jq_filter(
464 filter_code: &str,
465 value: &serde_json::Value,
466 cache: &mut JaqFilterCache,
467) -> Option<String> {
468 run_jq_filter_cached(filter_code, value, cache).ok()
469}
470
471pub fn apply_jq_filter_result(
479 filter_code: &str,
480 value: &serde_json::Value,
481) -> Result<String, String> {
482 let filter = compile_jq_filter(filter_code)?;
483 execute_jq_filter(&filter, value)
484}
485
486fn format_load_errors(errs: &load::Errors<&str, ()>) -> String {
491 for (_file, err) in errs {
494 match err {
495 load::Error::Io(ios) => {
496 if let Some((_path, msg)) = ios.first() {
497 return format!("jq filter error (IO): {msg}");
498 }
499 }
500 load::Error::Lex(lex_errs) => {
501 if let Some((expect, span)) = lex_errs.first() {
502 return format!(
503 "jq filter syntax error: expected {} near {:?}",
504 expect.as_str(),
505 span
506 );
507 }
508 }
509 load::Error::Parse(parse_errs) => {
510 if let Some((expect, _token)) = parse_errs.first() {
511 return format!("jq filter parse error: expected {}", expect.as_str());
512 }
513 }
514 }
515 }
516 "jq filter error: invalid filter syntax".to_owned()
517}
518
519fn compile_jq_filter(filter_code: &str) -> Result<jaq_core::compile::Filter<Native<D>>, String> {
524 let program = File {
525 code: filter_code,
526 path: (),
527 };
528 let defs = jaq_core::defs()
529 .chain(jaq_std::defs())
530 .chain(jaq_json::defs());
531 let loader = Loader::new(defs);
532 let arena = Arena::default();
533
534 let modules = loader
535 .load(&arena, program)
536 .map_err(|errs| format_load_errors(&errs))?;
537
538 let funs = jaq_core::funs::<D>()
539 .chain(jaq_std::funs::<D>())
540 .chain(jaq_json::funs::<D>());
541 Compiler::default()
542 .with_funs(funs)
543 .compile(modules)
544 .map_err(|errs| {
545 let first = errs.iter().flat_map(|(_file, undefs)| undefs.iter()).next();
548 if let Some((name, undef)) = first {
549 format!("jq filter error: undefined {} {:?}", undef.as_str(), name)
550 } else {
551 "jq filter error: compilation failed".to_owned()
552 }
553 })
554}
555
556fn execute_jq_filter(
558 filter: &jaq_core::compile::Filter<Native<D>>,
559 value: &serde_json::Value,
560) -> Result<String, String> {
561 let input: Val = serde_json::from_value(value.clone())
562 .map_err(|e| format!("jq input conversion error: {e}"))?;
563 let ctx = Ctx::<D>::new(&filter.lut, Vars::new([]));
564
565 let mut parts = Vec::new();
566 for result in filter.id.run((ctx, input)).map(jaq_core::unwrap_valr) {
567 match result {
568 Ok(val) => {
569 let s = match val {
570 Val::TStr(ref s) | Val::BStr(ref s) => match std::str::from_utf8(s) {
571 Ok(valid) => valid.to_owned(),
572 Err(_) => String::from_utf8_lossy(s).into_owned(),
573 },
574 other => other.to_string(),
577 };
578 parts.push(s);
579 }
580 Err(e) => return Err(format!("jq runtime error: {e}")),
581 }
582 }
583
584 Ok(parts.join("\n"))
585}
586
587fn run_jq_filter_cached(
589 filter_code: &str,
590 value: &serde_json::Value,
591 cache: &mut JaqFilterCache,
592) -> Result<String, String> {
593 if let Some(filter) = cache.get(filter_code) {
594 return execute_jq_filter(filter, value);
595 }
596 let compiled = compile_jq_filter(filter_code)?;
597 let filter = cache.entry(filter_code.to_owned()).or_insert(compiled);
598 execute_jq_filter(filter, value)
599}
600
601fn build_file_object_filter(map: &serde_json::Map<String, serde_json::Value>) -> String {
618 let mut parts = vec![r#""\"\(.file)\" (\(.modified))""#.to_owned()];
620
621 if map.contains_key("title") {
623 parts.push(r#"" title: \(if .title != null then .title else "(none)" end)""#.to_owned());
624 }
625
626 if map.contains_key("properties") {
628 parts.push(
629 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(),
630 );
631 }
632
633 if map.contains_key("properties_typed") {
635 parts.push(
636 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(),
637 );
638 }
639
640 if map.contains_key("tags") {
642 parts.push(
643 r#"if (.tags | length) > 0 then " tags: [\(.tags | join(", "))]" else empty end"#
644 .to_owned(),
645 );
646 }
647
648 if map.contains_key("sections") {
651 parts.push(
652 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(),
653 );
654 }
655
656 if map.contains_key("tasks") {
658 parts.push(
659 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(),
660 );
661 }
662
663 if map.contains_key("matches") {
665 parts.push(
666 r#"if (.matches | length) > 0 then " matches:\n\(.matches | map(" line \(.line) (\(.section)): \(.text)") | join("\n"))" else empty end"#.to_owned(),
667 );
668 }
669
670 if map.contains_key("score") {
672 parts.push(r#"" score: \(.score)""#.to_owned());
673 }
674
675 if map.contains_key("links") {
677 parts.push(
678 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(),
679 );
680 }
681
682 if map.contains_key("backlinks") {
684 parts.push(
685 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(),
686 );
687 }
688
689 parts.join(", ")
690}
691
692fn format_value_as_text(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
698 match value {
699 serde_json::Value::Array(arr) => {
700 let is_type_list = arr.first().and_then(|v| v.as_object()).is_some_and(|m| {
702 key_signature(m) == "has_filename_template,property_count,required,type"
703 });
704 if is_type_list {
705 return arr
706 .iter()
707 .filter_map(|v| v.as_object())
708 .map(format_type_list_entry_text)
709 .collect::<Vec<_>>()
710 .join("\n\n");
711 }
712 let is_file_objects = arr
714 .first()
715 .and_then(|v| v.as_object())
716 .is_some_and(|m| m.contains_key("file") && m.contains_key("modified"));
717 let sep = if is_file_objects { "\n\n" } else { "\n" };
718 arr.iter()
719 .map(|v| format_value_as_text(v, cache))
720 .collect::<Vec<_>>()
721 .join(sep)
722 }
723 serde_json::Value::Object(map) => {
724 let sig = key_signature(map);
725 if let Some(filter) = lookup_filter(&sig)
726 && let Some(output) = apply_jq_filter(filter, value, cache)
727 {
728 return output;
729 }
730 if sig == "defaults,filename_template,properties,required,type" {
732 return format_type_show_text(map);
733 }
734 if map.contains_key("total")
736 && map.contains_key("files")
737 && let Some(serde_json::Value::Array(arr)) = map.get("files")
738 {
739 let is_lint = arr
740 .first()
741 .and_then(|v| v.as_object())
742 .is_some_and(|m| m.contains_key("file") && m.contains_key("violations"))
743 || arr.is_empty();
744 if is_lint {
745 return format_lint_output_text(map);
746 }
747 }
748 if map.contains_key("file") && map.contains_key("modified") {
750 let filter = build_file_object_filter(map);
751 if let Some(output) = apply_jq_filter(&filter, value, cache) {
752 return output;
753 }
754 }
755 format_object_generic(map, cache)
757 }
758 other => format_scalar(other, cache),
759 }
760}
761
762fn format_lint_output_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
766 use std::fmt::Write as _;
767
768 let mut s = String::new();
769 let dry_run = map
770 .get("dry_run")
771 .and_then(serde_json::Value::as_bool)
772 .unwrap_or(false);
773
774 if let Some(fixes_arr) = map.get("fixes").and_then(|f| f.as_array()) {
776 let verb = if dry_run { "Would fix" } else { "Fixed" };
777 for file_fix in fixes_arr {
778 let file = file_fix
779 .get("file")
780 .and_then(serde_json::Value::as_str)
781 .unwrap_or("?");
782 let actions = file_fix.get("actions").and_then(|a| a.as_array());
783 let Some(actions) = actions else { continue };
784 if actions.is_empty() {
785 continue;
786 }
787 let _ = writeln!(s, "{verb} {file}:");
788 for a in actions {
789 let kind = a
790 .get("kind")
791 .and_then(serde_json::Value::as_str)
792 .unwrap_or("");
793 let property = a
794 .get("property")
795 .and_then(serde_json::Value::as_str)
796 .unwrap_or("?");
797 let new = a
798 .get("new")
799 .and_then(serde_json::Value::as_str)
800 .unwrap_or("");
801 let old = a.get("old").and_then(serde_json::Value::as_str);
802 match (kind, old) {
803 ("insert-default", _) => {
804 let _ = writeln!(s, " insert {property} = {new:?}");
805 }
806 ("infer-type", _) => {
807 let _ = writeln!(s, " infer type = {new:?}");
808 }
809 ("fix-enum-typo", Some(old_v)) => {
810 let _ = writeln!(s, " enum {property}: {old_v:?} -> {new:?}");
811 }
812 ("normalize-date", Some(old_v)) => {
813 let _ = writeln!(s, " date {property}: {old_v:?} -> {new:?}");
814 }
815 _ => {
816 let _ = writeln!(s, " {kind} {property} = {new:?}");
817 }
818 }
819 }
820 }
821 }
822
823 let files = map.get("files").and_then(|f| f.as_array());
825 let mut error_count: usize = 0;
826 let mut warn_count: usize = 0;
827 let mut files_with_issues: usize = 0;
828 if let Some(files) = files {
829 for file_entry in files {
830 let file = file_entry
831 .get("file")
832 .and_then(serde_json::Value::as_str)
833 .unwrap_or("?");
834 let violations = file_entry.get("violations").and_then(|v| v.as_array());
835 let Some(violations) = violations else {
836 continue;
837 };
838 if violations.is_empty() {
839 continue;
840 }
841 files_with_issues += 1;
842 let _ = writeln!(s, "{file}:");
843 for v in violations {
844 let severity = v
845 .get("severity")
846 .and_then(serde_json::Value::as_str)
847 .unwrap_or("warn");
848 let message = v
849 .get("message")
850 .and_then(serde_json::Value::as_str)
851 .unwrap_or("");
852 let pad = if severity == "error" {
853 error_count += 1;
854 "error"
855 } else {
856 warn_count += 1;
857 "warn "
858 };
859 let _ = writeln!(s, " {pad} {message}");
860 }
861 }
862 }
863
864 let files_checked: u64 = map
866 .get("files_checked")
867 .and_then(serde_json::Value::as_u64)
868 .unwrap_or(0);
869 let files_label = if files_checked == 1 { "file" } else { "files" };
870 if error_count == 0 && warn_count == 0 {
871 let _ = write!(s, "{files_checked} {files_label} checked, no issues");
872 } else {
873 let _ = write!(
874 s,
875 "{files_checked} {files_label} checked, {files_with_issues} with issues ({error_count} errors, {warn_count} warnings)",
876 );
877 }
878
879 let fix_count: usize = map
880 .get("fixes")
881 .and_then(|f| f.as_array())
882 .map_or(0, |arr| {
883 arr.iter()
884 .filter_map(|f| f.get("actions").and_then(|a| a.as_array()).map(Vec::len))
885 .sum()
886 });
887 if fix_count > 0 {
888 let fixed_label = if dry_run { "would fix" } else { "fixed" };
889 let _ = write!(s, " — {fixed_label} {fix_count}");
890 }
891
892 s
893}
894
895fn format_type_show_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
915 use std::fmt::Write as _;
916
917 let mut s = String::new();
918
919 let type_name = map
920 .get("type")
921 .and_then(serde_json::Value::as_str)
922 .unwrap_or("?");
923 let _ = write!(s, "Type: {type_name}");
924
925 if let Some(serde_json::Value::Array(req)) = map.get("required")
927 && !req.is_empty()
928 {
929 let list: Vec<&str> = req.iter().filter_map(serde_json::Value::as_str).collect();
930 let _ = write!(s, "\n\nRequired: {}", list.join(", "));
931 }
932
933 if let Some(serde_json::Value::Object(defaults)) = map.get("defaults")
935 && !defaults.is_empty()
936 {
937 let _ = write!(s, "\n\nDefaults:");
938 let mut keys: Vec<&str> = defaults.keys().map(String::as_str).collect();
939 keys.sort_unstable();
940 for key in keys {
941 if let Some(value) = defaults.get(key) {
942 let display = match value {
943 serde_json::Value::String(sv) => sv.clone(),
944 other => other.to_string(),
945 };
946 let _ = write!(s, "\n {key}: {display}");
947 }
948 }
949 }
950
951 if let Some(serde_json::Value::Object(props)) = map.get("properties")
953 && !props.is_empty()
954 {
955 let _ = write!(s, "\n\nProperties:");
956 let mut prop_names: Vec<&str> = props.keys().map(String::as_str).collect();
957 prop_names.sort_unstable();
958 for name in prop_names {
959 let Some(prop_val) = props.get(name) else {
960 continue;
961 };
962 let _ = write!(s, "\n {name}:");
963 if let Some(obj) = prop_val.as_object() {
964 let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
967 keys.sort_unstable_by(|a, b| {
968 if *a == "type" {
969 std::cmp::Ordering::Less
970 } else if *b == "type" {
971 std::cmp::Ordering::Greater
972 } else {
973 a.cmp(b)
974 }
975 });
976 for key in keys {
977 if let Some(v) = obj.get(key) {
978 let display = match v {
979 serde_json::Value::Array(arr) => arr
980 .iter()
981 .filter_map(serde_json::Value::as_str)
982 .collect::<Vec<_>>()
983 .join(", "),
984 serde_json::Value::String(sv) => sv.clone(),
985 other => other.to_string(),
986 };
987 let _ = write!(s, "\n {key}: {display}");
988 }
989 }
990 }
991 s.push('\n'); }
993 }
994
995 if let Some(serde_json::Value::String(tmpl)) = map.get("filename_template") {
997 let _ = write!(s, "\nFilename template: {tmpl}");
998 }
999
1000 s
1001}
1002
1003fn format_type_list_entry_text(map: &serde_json::Map<String, serde_json::Value>) -> String {
1015 use std::fmt::Write as _;
1016
1017 let mut s = String::new();
1018
1019 let type_name = map
1020 .get("type")
1021 .and_then(serde_json::Value::as_str)
1022 .unwrap_or("?");
1023
1024 let req_arr: &[serde_json::Value] = map
1025 .get("required")
1026 .and_then(serde_json::Value::as_array)
1027 .map_or(&[], Vec::as_slice);
1028 let req_count = req_arr.len();
1029
1030 let prop_count = map
1031 .get("property_count")
1032 .and_then(serde_json::Value::as_u64)
1033 .unwrap_or(0);
1034
1035 let has_filename = map
1036 .get("has_filename_template")
1037 .and_then(serde_json::Value::as_bool)
1038 .unwrap_or(false);
1039
1040 let prop_label = if prop_count == 1 {
1041 "property"
1042 } else {
1043 "properties"
1044 };
1045 let _ = write!(
1046 s,
1047 "{type_name} ({req_count} required, {prop_count} {prop_label})"
1048 );
1049
1050 if !req_arr.is_empty() {
1051 let list: Vec<&str> = req_arr
1052 .iter()
1053 .filter_map(serde_json::Value::as_str)
1054 .collect();
1055 let _ = write!(s, "\n required: {}", list.join(", "));
1056 }
1057
1058 if has_filename {
1059 let _ = write!(s, "\n filename: (see type details)");
1060 }
1061
1062 s
1063}
1064
1065fn format_object_generic(
1067 map: &serde_json::Map<String, serde_json::Value>,
1068 cache: &mut JaqFilterCache,
1069) -> String {
1070 map.iter()
1071 .map(|(k, v)| format!("{k}: {}", format_value_as_text(v, cache)))
1072 .collect::<Vec<_>>()
1073 .join("\n")
1074}
1075
1076fn format_scalar(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
1078 match value {
1079 serde_json::Value::String(s) => s.clone(),
1080 serde_json::Value::Number(n) => n.to_string(),
1081 serde_json::Value::Bool(b) => b.to_string(),
1082 serde_json::Value::Null => "null".to_owned(),
1083 serde_json::Value::Array(arr) => {
1084 let items: Vec<String> = arr.iter().map(|v| format_scalar(v, cache)).collect();
1085 items.join(", ")
1086 }
1087 serde_json::Value::Object(_) => format_value_as_text(value, cache),
1088 }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::*;
1094 use serde_json::json;
1095
1096 fn jq(filter: &str, val: &serde_json::Value) -> Option<String> {
1098 apply_jq_filter(filter, val, &mut JaqFilterCache::new())
1099 }
1100
1101 fn fmt(val: &serde_json::Value) -> String {
1102 format_value_as_text(val, &mut JaqFilterCache::new())
1103 }
1104
1105 fn scalar(val: &serde_json::Value) -> String {
1106 format_scalar(val, &mut JaqFilterCache::new())
1107 }
1108
1109 #[test]
1112 fn format_json_error() {
1113 let out = format_error(
1114 Format::Json,
1115 "file not found",
1116 Some("foo/bar"),
1117 Some("did you mean foo/bar.md?"),
1118 None,
1119 );
1120 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
1121 assert_eq!(parsed["error"], "file not found");
1122 assert_eq!(parsed["hint"], "did you mean foo/bar.md?");
1123 assert!(parsed.get("cause").is_none());
1124 }
1125
1126 #[test]
1127 fn format_text_error() {
1128 let out = format_error(Format::Text, "file not found", Some("foo"), None, None);
1129 assert!(out.contains("Error: file not found"));
1130 assert!(out.contains("path: foo"));
1131 }
1132
1133 #[test]
1134 fn format_json_success() {
1135 let val = json!({"name": "test", "value": 42});
1136 let out = format_success(Format::Json, &val);
1137 assert!(out.contains("\"name\": \"test\""));
1138 }
1139
1140 #[test]
1143 fn apply_jq_filter_simple() {
1144 let val = json!({"name": "hello", "count": 3});
1145 let result = jq(r#""\(.name): \(.count)""#, &val);
1146 assert_eq!(result.as_deref(), Some("hello: 3"));
1147 }
1148
1149 #[test]
1150 fn apply_jq_filter_array_map() {
1151 let val = json!(["a", "b", "c"]);
1152 let result = jq(".[]", &val);
1153 assert_eq!(result.as_deref(), Some("a\nb\nc"));
1154 }
1155
1156 #[test]
1157 fn apply_jq_filter_invalid_returns_none() {
1158 let val = json!({"x": 1});
1159 let result = jq("this is not valid jq %%%", &val);
1160 assert!(result.is_none());
1161 }
1162
1163 #[test]
1166 fn property_info_filter() {
1167 let val = json!({"name": "title", "type": "text", "value": "My Note"});
1168 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
1169 assert!(out.contains("title"));
1170 assert!(out.contains("text"));
1171 assert!(out.contains("My Note"));
1172 }
1173
1174 #[test]
1175 fn property_info_filter_list_value() {
1176 let val = json!({"name": "tags", "type": "list", "value": ["rust", "cli"]});
1177 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
1178 assert!(out.contains("tags"));
1179 assert!(out.contains("list"));
1180 assert!(out.contains("[rust, cli]"), "expected [rust, cli]: {out}");
1182 assert!(!out.contains("[\"rust\""));
1183 }
1184
1185 #[test]
1186 fn property_summary_entry_filter() {
1187 let val = json!({"count": 7, "name": "title", "type": "text"});
1188 let out = jq(PROPERTY_SUMMARY_ENTRY_FILTER, &val).unwrap();
1189 assert!(out.contains("title"));
1190 assert!(out.contains("text"));
1191 assert!(out.contains("7 files"));
1192 }
1193
1194 #[test]
1195 fn tag_summary_filter() {
1196 let val = json!({
1197 "tags": [{"name": "rust", "count": 3}, {"name": "cli", "count": 1}],
1198 "total": 2
1199 });
1200 let out = jq(TAG_SUMMARY_FILTER, &val).unwrap();
1201 assert!(out.contains("2 unique tags"));
1202 assert!(out.contains("rust"));
1203 assert!(out.contains("3 files"));
1204 }
1205
1206 #[test]
1209 fn link_info_target_only_filter() {
1210 let val = json!({"target": "broken-link"});
1211 let out = jq(LINK_INFO_TARGET_FILTER, &val).unwrap();
1212 assert!(out.contains("broken-link"));
1213 assert!(out.contains("unresolved"));
1214 }
1215
1216 #[test]
1217 fn link_info_with_path_filter() {
1218 let val = json!({"path": "note-b.md", "target": "note-b"});
1219 let out = jq(LINK_INFO_PATH_FILTER, &val).unwrap();
1220 assert!(out.contains("note-b"));
1221 assert!(out.contains("note-b.md"));
1222 }
1223
1224 #[test]
1227 fn task_count_filter() {
1228 let val = json!({"done": 3, "total": 5});
1229 let out = jq(TASK_COUNT_FILTER, &val).unwrap();
1230 assert_eq!(out, "[3/5]");
1231 }
1232
1233 #[test]
1234 fn outline_section_filter() {
1235 let val = json!({
1236 "code_blocks": [],
1237 "heading": "Introduction",
1238 "level": 1,
1239 "line": 5,
1240 "links": ["[[other]]"]
1241 });
1242 let out = jq(OUTLINE_SECTION_FILTER, &val).unwrap();
1243 assert!(out.contains('#'));
1244 assert!(out.contains("Introduction"));
1245 assert!(out.contains("[[other]]"));
1246 }
1247
1248 #[test]
1249 fn outline_section_with_tasks_filter() {
1250 let val = json!({
1251 "code_blocks": [],
1252 "heading": "Tasks",
1253 "level": 2,
1254 "line": 10,
1255 "links": [],
1256 "tasks": {"done": 2, "total": 4}
1257 });
1258 let out = jq(OUTLINE_SECTION_WITH_TASKS_FILTER, &val).unwrap();
1259 assert!(out.contains("##"));
1260 assert!(out.contains("Tasks"));
1261 assert!(out.contains("[2/4]"));
1262 }
1263
1264 #[test]
1267 fn find_task_info_filter_done() {
1268 let val = json!({
1269 "done": true,
1270 "line": 42,
1271 "section": "Implementation",
1272 "status": "x",
1273 "text": "Write the tests"
1274 });
1275 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
1276 assert!(out.contains("[x]"));
1277 assert!(out.contains("Write the tests"));
1278 assert!(out.contains("line 42"));
1279 assert!(out.contains("Implementation"));
1280 }
1281
1282 #[test]
1283 fn find_task_info_filter_not_done() {
1284 let val = json!({
1285 "done": false,
1286 "line": 7,
1287 "section": "Todo",
1288 "status": " ",
1289 "text": "Review PR"
1290 });
1291 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
1292 assert!(out.contains("[ ]"));
1293 assert!(out.contains("Review PR"));
1294 assert!(out.contains("line 7"));
1295 assert!(out.contains("Todo"));
1296 }
1297
1298 #[test]
1299 fn find_task_info_via_format_value_as_text() {
1300 let val = json!({
1302 "done": true,
1303 "line": 5,
1304 "section": "Goals",
1305 "status": "x",
1306 "text": "Ship it"
1307 });
1308 let out = fmt(&val);
1309 assert!(out.contains("[x]"));
1310 assert!(out.contains("Ship it"));
1311 assert!(
1312 !out.contains("done: true"),
1313 "should not use generic fallback"
1314 );
1315 }
1316
1317 #[test]
1320 fn content_match_filter() {
1321 let val = json!({
1322 "line": 15,
1323 "section": "Background",
1324 "text": "This is the matching line"
1325 });
1326 let out = jq(CONTENT_MATCH_FILTER, &val).unwrap();
1327 assert!(out.contains("line 15"));
1328 assert!(out.contains("Background"));
1329 assert!(out.contains("This is the matching line"));
1330 }
1331
1332 #[test]
1333 fn content_match_via_format_value_as_text() {
1334 let val = json!({
1335 "line": 3,
1336 "section": "Intro",
1337 "text": "hello world"
1338 });
1339 let out = fmt(&val);
1340 assert!(out.contains("line 3"));
1341 assert!(out.contains("hello world"));
1342 assert!(!out.contains("line: 3"), "should not use generic fallback");
1343 }
1344
1345 #[test]
1348 fn property_value_mutation_filter_with_modified() {
1349 let val = json!({
1352 "modified": ["note-a.md", "note-b.md"],
1353 "property": "status",
1354 "scanned": 2,
1355 "skipped": [],
1356 "total": 2,
1357 "value": "done"
1358 });
1359 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1360 assert!(out.contains("status=done"));
1361 assert!(out.contains("2/2 modified"));
1362 assert!(
1363 !out.contains("scanned"),
1364 "no scanned suffix when scanned == total"
1365 );
1366 assert!(out.contains("note-a.md"));
1367 assert!(out.contains("note-b.md"));
1368 }
1369
1370 #[test]
1371 fn property_value_mutation_filter_all_skipped() {
1372 let val = json!({
1373 "modified": [],
1374 "property": "priority",
1375 "scanned": 1,
1376 "skipped": ["note-a.md"],
1377 "total": 1,
1378 "value": "high"
1379 });
1380 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1381 assert!(out.contains("priority=high"));
1382 assert!(out.contains("0/1 modified"));
1383 assert!(!out.contains("note-a.md"));
1385 }
1386
1387 #[test]
1388 fn property_value_mutation_filter_with_where_filter() {
1389 let val = json!({
1391 "modified": ["note-a.md"],
1392 "property": "status",
1393 "scanned": 5,
1394 "skipped": [],
1395 "total": 1,
1396 "value": "done"
1397 });
1398 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1399 assert!(out.contains("status=done"));
1400 assert!(out.contains("1/1 modified"));
1401 assert!(out.contains("(5 scanned)"));
1402 }
1403
1404 #[test]
1405 fn property_value_mutation_via_format_value_as_text() {
1406 let val = json!({
1407 "dry_run": false,
1408 "modified": ["notes/a.md"],
1409 "property": "status",
1410 "scanned": 1,
1411 "skipped": [],
1412 "total": 1,
1413 "value": "done"
1414 });
1415 let out = fmt(&val);
1416 assert!(out.contains("status=done"));
1417 assert!(
1418 !out.contains("modified: "),
1419 "should not use generic fallback"
1420 );
1421 }
1422
1423 #[test]
1424 fn property_mutation_filter_no_value() {
1425 let val = json!({
1427 "dry_run": false,
1428 "modified": ["note.md"],
1429 "property": "draft",
1430 "scanned": 1,
1431 "skipped": [],
1432 "total": 1
1433 });
1434 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1435 assert!(out.contains("draft"));
1436 assert!(out.contains("1/1 modified"));
1437 assert!(
1438 !out.contains("scanned"),
1439 "no scanned suffix when scanned == total"
1440 );
1441 assert!(out.contains("note.md"));
1442 }
1443
1444 #[test]
1445 fn property_mutation_filter_no_value_with_where_filter() {
1446 let val = json!({
1448 "dry_run": false,
1449 "modified": ["note.md"],
1450 "property": "draft",
1451 "scanned": 7,
1452 "skipped": [],
1453 "total": 1
1454 });
1455 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1456 assert!(out.contains("draft"));
1457 assert!(out.contains("1/1 modified"));
1458 assert!(out.contains("(7 scanned)"));
1459 }
1460
1461 #[test]
1462 fn tag_mutation_filter_with_modified() {
1463 let val = json!({
1465 "dry_run": false,
1466 "modified": ["a.md", "b.md"],
1467 "scanned": 3,
1468 "skipped": ["c.md"],
1469 "tag": "rust",
1470 "total": 3
1471 });
1472 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1473 assert!(out.contains("rust"));
1474 assert!(out.contains("2/3 modified"));
1475 assert!(
1476 !out.contains("scanned"),
1477 "no scanned suffix when scanned == total"
1478 );
1479 assert!(out.contains("a.md"));
1480 assert!(out.contains("b.md"));
1481 assert!(!out.contains("c.md"));
1482 }
1483
1484 #[test]
1485 fn tag_mutation_filter_with_where_filter() {
1486 let val = json!({
1488 "dry_run": false,
1489 "modified": ["a.md"],
1490 "scanned": 10,
1491 "skipped": [],
1492 "tag": "rust",
1493 "total": 1
1494 });
1495 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1496 assert!(out.contains("rust"));
1497 assert!(out.contains("1/1 modified"));
1498 assert!(out.contains("(10 scanned)"));
1499 }
1500
1501 #[test]
1502 fn tag_mutation_via_format_value_as_text() {
1503 let val = json!({
1504 "dry_run": false,
1505 "modified": [],
1506 "scanned": 1,
1507 "skipped": ["note.md"],
1508 "tag": "cli",
1509 "total": 1
1510 });
1511 let out = fmt(&val);
1512 assert!(out.contains("cli"));
1513 assert!(!out.contains("tag: cli"), "should not use generic fallback");
1514 }
1515
1516 #[test]
1519 fn property_value_mutation_dry_run_prefix() {
1520 let val = json!({
1521 "dry_run": true,
1522 "modified": ["note.md"],
1523 "property": "status",
1524 "scanned": 1,
1525 "skipped": [],
1526 "total": 1,
1527 "value": "done"
1528 });
1529 let out = fmt(&val);
1530 assert!(
1531 out.contains("[dry-run] status=done"),
1532 "dry-run prefix missing: {out}"
1533 );
1534 }
1535
1536 #[test]
1537 fn tag_mutation_dry_run_prefix() {
1538 let val = json!({
1539 "dry_run": true,
1540 "modified": ["note.md"],
1541 "scanned": 1,
1542 "skipped": [],
1543 "tag": "rust",
1544 "total": 1
1545 });
1546 let out = fmt(&val);
1547 assert!(
1548 out.contains("[dry-run] rust"),
1549 "dry-run prefix missing: {out}"
1550 );
1551 }
1552
1553 #[test]
1554 fn property_value_mutation_no_dry_run_prefix() {
1555 let val = json!({
1556 "dry_run": false,
1557 "modified": ["note.md"],
1558 "property": "status",
1559 "scanned": 1,
1560 "skipped": [],
1561 "total": 1,
1562 "value": "done"
1563 });
1564 let out = fmt(&val);
1565 assert!(
1566 !out.contains("[dry-run]"),
1567 "should not have dry-run prefix: {out}"
1568 );
1569 }
1570
1571 #[test]
1574 fn build_file_object_filter_minimal() {
1575 let map: serde_json::Map<String, serde_json::Value> =
1577 serde_json::from_str(r#"{"file": "notes/foo.md", "modified": "2024-01-01"}"#).unwrap();
1578 let filter = build_file_object_filter(&map);
1579 let val = json!({"file": "notes/foo.md", "modified": "2024-01-01"});
1580 let out = jq(&filter, &val).unwrap();
1581 assert!(out.contains("notes/foo.md"));
1582 assert!(out.contains("2024-01-01"));
1583 }
1584
1585 #[test]
1586 fn build_file_object_filter_with_tags() {
1587 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1588 r#"{"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]}"#,
1589 )
1590 .unwrap();
1591 let filter = build_file_object_filter(&map);
1592 let val = json!({"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]});
1593 let out = jq(&filter, &val).unwrap();
1594 assert!(out.contains("foo.md"));
1595 assert!(out.contains("tags: [rust, cli]"));
1596 }
1597
1598 #[test]
1599 fn build_file_object_filter_with_properties() {
1600 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1601 r#"{"file": "foo.md", "modified": "2024-01-01", "properties": {"status": "done"}}"#,
1602 )
1603 .unwrap();
1604 let filter = build_file_object_filter(&map);
1605 let val = json!({
1606 "file": "foo.md",
1607 "modified": "2024-01-01",
1608 "properties": {"status": "done"}
1609 });
1610 let out = jq(&filter, &val).unwrap();
1611 assert!(out.contains("foo.md"));
1612 assert!(out.contains("properties:"));
1613 assert!(out.contains("status: done"));
1614 }
1615
1616 #[test]
1617 fn build_file_object_filter_with_tasks() {
1618 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1619 r#"{"file": "foo.md", "modified": "2024-01-01", "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]}"#,
1620 )
1621 .unwrap();
1622 let filter = build_file_object_filter(&map);
1623 let val = json!({
1624 "file": "foo.md",
1625 "modified": "2024-01-01",
1626 "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]
1627 });
1628 let out = jq(&filter, &val).unwrap();
1629 assert!(out.contains("foo.md"));
1630 assert!(out.contains("tasks:"));
1631 assert!(out.contains("[x] Ship it"));
1632 assert!(out.contains("line 5"));
1633 }
1634
1635 #[test]
1636 fn build_file_object_filter_with_sections() {
1637 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1638 r#"{"file": "foo.md", "modified": "2024-01-01", "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]}"#,
1639 )
1640 .unwrap();
1641 let filter = build_file_object_filter(&map);
1642 let val = json!({
1643 "file": "foo.md",
1644 "modified": "2024-01-01",
1645 "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]
1646 });
1647 let out = jq(&filter, &val).unwrap();
1648 assert!(out.contains("foo.md"));
1649 assert!(out.contains("sections:"));
1650 assert!(out.contains("# Intro"));
1651 }
1652
1653 #[test]
1654 fn build_file_object_filter_with_matches() {
1655 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1656 r#"{"file": "foo.md", "modified": "2024-01-01", "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]}"#,
1657 )
1658 .unwrap();
1659 let filter = build_file_object_filter(&map);
1660 let val = json!({
1661 "file": "foo.md",
1662 "modified": "2024-01-01",
1663 "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]
1664 });
1665 let out = jq(&filter, &val).unwrap();
1666 assert!(out.contains("foo.md"));
1667 assert!(out.contains("matches:"));
1668 assert!(out.contains("line 3 (Intro): hello world"));
1669 }
1670
1671 #[test]
1672 fn build_file_object_filter_with_links() {
1673 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1674 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "bar", "path": "bar.md"}]}"#,
1675 )
1676 .unwrap();
1677 let filter = build_file_object_filter(&map);
1678 let val = json!({
1679 "file": "foo.md",
1680 "modified": "2024-01-01",
1681 "links": [{"target": "bar", "path": "bar.md"}]
1682 });
1683 let out = jq(&filter, &val).unwrap();
1684 assert!(out.contains("foo.md"));
1685 assert!(out.contains("links:"));
1686 assert!(out.contains(r#""bar" → "bar.md""#));
1687 }
1688
1689 #[test]
1690 fn build_file_object_filter_unresolved_link() {
1691 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1692 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "missing"}]}"#,
1693 )
1694 .unwrap();
1695 let filter = build_file_object_filter(&map);
1696 let val = json!({
1697 "file": "foo.md",
1698 "modified": "2024-01-01",
1699 "links": [{"target": "missing"}]
1700 });
1701 let out = jq(&filter, &val).unwrap();
1702 assert!(out.contains(r#""missing" (unresolved)"#));
1703 }
1704
1705 #[test]
1708 fn file_object_text_rendering_minimal() {
1709 let val = json!({"file": "notes/foo.md", "modified": "2024-01-15"});
1710 let out = fmt(&val);
1711 assert!(out.contains("notes/foo.md"));
1712 assert!(out.contains("2024-01-15"));
1713 assert!(!out.contains("file: notes/foo.md"));
1715 }
1716
1717 #[test]
1718 fn file_object_text_rendering_full() {
1719 let val = json!({
1720 "file": "notes/project.md",
1721 "modified": "2024-03-01",
1722 "tags": ["rust", "work"],
1723 "properties": {"status": "active"},
1724 "tasks": [
1725 {"done": false, "line": 10, "section": "Todo", "status": " ", "text": "Fix bug"},
1726 {"done": true, "line": 20, "section": "Done", "status": "x", "text": "Write docs"}
1727 ]
1728 });
1729 let out = fmt(&val);
1730 assert!(out.contains("notes/project.md"));
1731 assert!(out.contains("properties:"));
1732 assert!(out.contains("status: active"));
1733 assert!(out.contains("tags: [rust, work]"));
1734 assert!(out.contains("tasks:"));
1735 assert!(out.contains("[ ] Fix bug"));
1736 assert!(out.contains("[x] Write docs"));
1737 }
1738
1739 #[test]
1742 fn array_of_file_objects_uses_blank_line_separator() {
1743 let val = json!([
1744 {"file": "a.md", "modified": "2024-01-01"},
1745 {"file": "b.md", "modified": "2024-01-02"}
1746 ]);
1747 let out = fmt(&val);
1748 assert!(out.contains("a.md"));
1749 assert!(out.contains("b.md"));
1750 assert!(
1752 out.contains("\n\n"),
1753 "expected blank-line separator between file objects"
1754 );
1755 }
1756
1757 #[test]
1758 fn array_of_non_file_objects_uses_single_newline() {
1759 let val = json!([
1760 {"count": 1, "name": "status", "type": "text"},
1761 {"count": 3, "name": "title", "type": "text"}
1762 ]);
1763 let out = fmt(&val);
1764 assert!(out.contains("status"));
1765 assert!(out.contains("title"));
1766 assert!(
1768 !out.contains("\n\n"),
1769 "non-file-objects should use single newline"
1770 );
1771 }
1772
1773 #[test]
1776 fn format_scalar_delegates_nested_objects() {
1777 let inner = json!({"count": 2, "name": "status", "type": "text"});
1780 let out = scalar(&inner);
1781 assert!(
1783 !out.contains("count=2"),
1784 "should delegate to format_value_as_text"
1785 );
1786 assert!(out.contains("status"));
1788 assert!(out.contains("2 files"));
1789 }
1790
1791 #[test]
1794 fn format_value_as_text_uses_filter_for_known_shape() {
1795 let val = json!({"count": 3, "name": "status", "type": "text"});
1797 let out = fmt(&val);
1798 assert!(out.contains("status"));
1799 assert!(out.contains("3 files"));
1800 assert!(!out.contains("count: 3"));
1802 }
1803
1804 #[test]
1805 fn format_value_as_text_falls_back_for_unknown_shape() {
1806 let val = json!({"foo": "bar", "baz": 42});
1807 let out = fmt(&val);
1808 assert!(out.contains("foo: bar") || out.contains("baz: 42"));
1810 }
1811
1812 #[test]
1813 fn mv_result_filter_applied() {
1814 let val = json!({
1815 "dry_run": false,
1816 "from": "sub/b.md",
1817 "to": "archive/b.md",
1818 "total_files_updated": 1,
1819 "total_links_updated": 1,
1820 "updated_files": [
1821 {
1822 "file": "a.md",
1823 "replacements": [
1824 {"old_text": "[[sub/b]]", "new_text": "[[archive/b]]", "line": 1}
1825 ]
1826 }
1827 ]
1828 });
1829 let sig = {
1831 let map = val.as_object().unwrap();
1832 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
1833 keys.sort_unstable();
1834 keys.join(",")
1835 };
1836 assert_eq!(
1837 sig,
1838 "dry_run,from,to,total_files_updated,total_links_updated,updated_files"
1839 );
1840 let filter_result = apply_jq_filter_result(MV_RESULT_FILTER, &val);
1842 assert!(filter_result.is_ok(), "filter error: {filter_result:?}");
1843 let out = filter_result.unwrap();
1844 assert!(out.contains("Moved sub/b.md"), "out: {out}");
1845 assert!(out.contains("archive/b.md"), "out: {out}");
1846 assert!(out.contains("[[sub/b]]"), "out: {out}");
1847 assert!(out.contains("[[archive/b]]"), "out: {out}");
1848 let found_filter =
1850 lookup_filter("dry_run,from,to,total_files_updated,total_links_updated,updated_files");
1851 assert!(
1852 found_filter.is_some(),
1853 "lookup_filter returned None for MvResult shape"
1854 );
1855 let formatted = fmt(&val);
1857 assert!(
1858 formatted.contains("Moved sub/b.md"),
1859 "formatted: {formatted}"
1860 );
1861 }
1862
1863 #[test]
1864 fn format_value_as_text_array_of_typed_objects() {
1865 let val = json!([
1866 {"path": "a.md", "tags": ["rust"]},
1867 {"path": "b.md", "tags": ["cli"]}
1868 ]);
1869 let out = fmt(&val);
1870 assert!(out.contains("a.md"));
1871 assert!(out.contains("b.md"));
1872 assert!(out.contains("rust"));
1873 assert!(out.contains("cli"));
1874 }
1875}