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) total\(if (.files.by_directory | length) > 0 then "\n\(.files.by_directory | map(" \"\(.directory)\": \(.count)") | join("\n"))" else "" end)\nLinks: \(.links.total) total, \(.links.broken) broken\nProperties: \(.properties | length) unique\nTags: \(.tags.total) unique\nStatus: \(if (.status | length) > 0 then (.status | map("\(.value) (\(.files | length))") | join(", ")) else "(none)" end)\nTasks: \(.tasks.done)/\(.tasks.total)\nOrphans: \(.orphans.total)\(if (.orphans.files | length) > 0 then "\n\(.orphans.files | map(" \"\(.)\"") | join("\n"))" else "" end)\nDead-ends: \(.dead_ends.total)\(if (.dead_ends.files | length) > 0 then "\n\(.dead_ends.files | map(" \"\(.)\"") | join("\n"))" else "" end)\nRecent:\(if (.recent_files | length) > 0 then "\n\(.recent_files | map(" \"\(.path)\"") | join("\n"))" else " (none)" end)""#;
329
330const FIND_TASK_INFO_FILTER: &str =
333 r#"" [\(if .done then "x" else " " end)] \(.text) (line \(.line), \(.section))""#;
334
335const CONTENT_MATCH_FILTER: &str = r#"" line \(.line) (\(.section)): \(.text)""#;
338
339const PROPERTY_VALUE_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.property)=\(.value): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map(" \"\(.)\"") | join("\n"))" else "" end)""#;
345
346const PROPERTY_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.property): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map(" \"\(.)\"") | join("\n"))" else "" end)""#;
352
353const TAG_MUTATION_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)\(.tag): \(.modified | length)/\(.total) modified\(if .scanned != .total then " (\(.scanned) scanned)" else "" end)\(if (.modified | length) > 0 then "\n\(.modified | map(" \"\(.)\"") | join("\n"))" else "" end)""#;
359
360const BACKLINKS_RESULT_FILTER: &str = r#"if (.backlinks | length) == 0 then "No backlinks found for \"\(.file)\"" else "\(.backlinks | length) \(if (.backlinks | length) == 1 then "backlink" else "backlinks" end) for \"\(.file)\"\n\(.backlinks | map(" \(.source): line \(.line)") | join("\n"))" end"#;
364
365const LINKS_FIX_FILTER: &str = r#""Broken links: \(.broken)\nFixable: \(.fixable)\nUnfixable: \(.unfixable)\nIgnored: \(.ignored)\nApplied: \(if .applied then "yes" else "no" end)\(if (.fixes | length) > 0 then "\n\(.fixes | map(" \(.source) line \(.line): \"\(.old_target)\" → \"\(.new_target)\"") | join("\n"))" else "" end)""#;
368
369const MV_RESULT_FILTER: &str = r#""\(if .dry_run then "[dry-run] " else "" end)Moved \(.from) → \(.to)\(.updated_files | if length > 0 then "\n" + (map(" \(.file): " + (.replacements | map(.old_text + " → " + .new_text) | join(", "))) | join("\n")) else "" end)""#;
372
373const VIEWS_LIST_ENTRY_FILTER: &str = r#""\(.name)\t\(.filters | to_entries | map("\(.key)=\(.value | if type == "array" then join(",") else tostring end)") | join(" "))""#;
376
377const VIEWS_MUTATION_RESULT_FILTER: &str = r#""\(.action): \(.name)""#;
380
381fn key_signature(map: &serde_json::Map<String, serde_json::Value>) -> String {
387 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
388 keys.sort_unstable();
389 keys.join(",")
390}
391
392fn lookup_filter(key_sig: &str) -> Option<&'static str> {
396 match key_sig {
397 "name,type,value" => Some(PROPERTY_INFO_FILTER),
399 "count,name,type" => Some(PROPERTY_SUMMARY_ENTRY_FILTER),
401 "tags,total" => Some(TAG_SUMMARY_FILTER),
403 "count,name" => Some(TAG_SUMMARY_ENTRY_FILTER),
405 "target" => Some(LINK_INFO_TARGET_FILTER),
407 "path,target" => Some(LINK_INFO_PATH_FILTER),
408 "label,target" => Some(LINK_INFO_LABEL_FILTER),
409 "label,path,target" => Some(LINK_INFO_FULL_FILTER),
410 "done,total" => Some(TASK_COUNT_FILTER),
412 "code_blocks,heading,level,line,links" => Some(OUTLINE_SECTION_FILTER),
414 "code_blocks,heading,level,line,links,tasks" => Some(OUTLINE_SECTION_WITH_TASKS_FILTER),
415 "done,line,status,text" => Some(TASK_INFO_FILTER),
417 "done,line,section,status,text" => Some(FIND_TASK_INFO_FILTER),
419 "line,section,text" => Some(CONTENT_MATCH_FILTER),
421 "done,file,line,status,text" => Some(TASK_READ_RESULT_FILTER),
423 "dead_ends,files,links,orphans,properties,recent_files,status,tags,tasks" => {
425 Some(VAULT_SUMMARY_FILTER)
426 }
427 "dry_run,modified,property,scanned,skipped,total,value" => {
430 Some(PROPERTY_VALUE_MUTATION_FILTER)
431 }
432 "dry_run,modified,property,scanned,skipped,total" => Some(PROPERTY_MUTATION_FILTER),
434 "dry_run,modified,scanned,skipped,tag,total" => Some(TAG_MUTATION_FILTER),
436 "backlinks,file" => Some(BACKLINKS_RESULT_FILTER),
438 "applied,broken,fixable,fixes,ignored,unfixable,unfixable_links" => Some(LINKS_FIX_FILTER),
440 "dry_run,from,to,total_files_updated,total_links_updated,updated_files" => {
442 Some(MV_RESULT_FILTER)
443 }
444 "filters,name" => Some(VIEWS_LIST_ENTRY_FILTER),
446 "action,name" => Some(VIEWS_MUTATION_RESULT_FILTER),
448 _ => None,
449 }
450}
451
452fn apply_jq_filter(
462 filter_code: &str,
463 value: &serde_json::Value,
464 cache: &mut JaqFilterCache,
465) -> Option<String> {
466 run_jq_filter_cached(filter_code, value, cache).ok()
467}
468
469pub fn apply_jq_filter_result(
477 filter_code: &str,
478 value: &serde_json::Value,
479) -> Result<String, String> {
480 let filter = compile_jq_filter(filter_code)?;
481 execute_jq_filter(&filter, value)
482}
483
484fn format_load_errors(errs: &load::Errors<&str, ()>) -> String {
489 for (_file, err) in errs {
492 match err {
493 load::Error::Io(ios) => {
494 if let Some((_path, msg)) = ios.first() {
495 return format!("jq filter error (IO): {msg}");
496 }
497 }
498 load::Error::Lex(lex_errs) => {
499 if let Some((expect, span)) = lex_errs.first() {
500 return format!(
501 "jq filter syntax error: expected {} near {:?}",
502 expect.as_str(),
503 span
504 );
505 }
506 }
507 load::Error::Parse(parse_errs) => {
508 if let Some((expect, _token)) = parse_errs.first() {
509 return format!("jq filter parse error: expected {}", expect.as_str());
510 }
511 }
512 }
513 }
514 "jq filter error: invalid filter syntax".to_owned()
515}
516
517fn compile_jq_filter(filter_code: &str) -> Result<jaq_core::compile::Filter<Native<D>>, String> {
522 let program = File {
523 code: filter_code,
524 path: (),
525 };
526 let defs = jaq_core::defs()
527 .chain(jaq_std::defs())
528 .chain(jaq_json::defs());
529 let loader = Loader::new(defs);
530 let arena = Arena::default();
531
532 let modules = loader
533 .load(&arena, program)
534 .map_err(|errs| format_load_errors(&errs))?;
535
536 let funs = jaq_core::funs::<D>()
537 .chain(jaq_std::funs::<D>())
538 .chain(jaq_json::funs::<D>());
539 Compiler::default()
540 .with_funs(funs)
541 .compile(modules)
542 .map_err(|errs| {
543 let first = errs.iter().flat_map(|(_file, undefs)| undefs.iter()).next();
546 if let Some((name, undef)) = first {
547 format!("jq filter error: undefined {} {:?}", undef.as_str(), name)
548 } else {
549 "jq filter error: compilation failed".to_owned()
550 }
551 })
552}
553
554fn execute_jq_filter(
556 filter: &jaq_core::compile::Filter<Native<D>>,
557 value: &serde_json::Value,
558) -> Result<String, String> {
559 let input: Val = serde_json::from_value(value.clone())
560 .map_err(|e| format!("jq input conversion error: {e}"))?;
561 let ctx = Ctx::<D>::new(&filter.lut, Vars::new([]));
562
563 let mut parts = Vec::new();
564 for result in filter.id.run((ctx, input)).map(jaq_core::unwrap_valr) {
565 match result {
566 Ok(val) => {
567 let s = match val {
568 Val::TStr(ref s) | Val::BStr(ref s) => match std::str::from_utf8(s) {
569 Ok(valid) => valid.to_owned(),
570 Err(_) => String::from_utf8_lossy(s).into_owned(),
571 },
572 other => other.to_string(),
575 };
576 parts.push(s);
577 }
578 Err(e) => return Err(format!("jq runtime error: {e}")),
579 }
580 }
581
582 Ok(parts.join("\n"))
583}
584
585fn run_jq_filter_cached(
587 filter_code: &str,
588 value: &serde_json::Value,
589 cache: &mut JaqFilterCache,
590) -> Result<String, String> {
591 if let Some(filter) = cache.get(filter_code) {
592 return execute_jq_filter(filter, value);
593 }
594 let compiled = compile_jq_filter(filter_code)?;
595 let filter = cache.entry(filter_code.to_owned()).or_insert(compiled);
596 execute_jq_filter(filter, value)
597}
598
599fn build_file_object_filter(map: &serde_json::Map<String, serde_json::Value>) -> String {
616 let mut parts = vec![r#""\"\(.file)\" (\(.modified))""#.to_owned()];
618
619 if map.contains_key("title") {
621 parts.push(r#"" title: \(if .title != null then .title else "(none)" end)""#.to_owned());
622 }
623
624 if map.contains_key("properties") {
626 parts.push(
627 r#"if (.properties | length) > 0 then " properties:\n\(.properties | to_entries | map(" \(.key): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
628 );
629 }
630
631 if map.contains_key("properties_typed") {
633 parts.push(
634 r#"if (.properties_typed | length) > 0 then " properties_typed:\n\(.properties_typed | map(" \(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | map(tostring) | join(", ")) + "]" else .value end)") | join("\n"))" else empty end"#.to_owned(),
635 );
636 }
637
638 if map.contains_key("tags") {
640 parts.push(
641 r#"if (.tags | length) > 0 then " tags: [\(.tags | join(", "))]" else empty end"#
642 .to_owned(),
643 );
644 }
645
646 if map.contains_key("sections") {
649 parts.push(
650 r##"if (.sections | length) > 0 then " sections:\n\(.sections | map(" \("#" * .level) \(.heading // "(pre-heading)")\(if .tasks then " [\(.tasks.done)/\(.tasks.total)]" else "" end)") | join("\n"))" else empty end"##.to_owned(),
651 );
652 }
653
654 if map.contains_key("tasks") {
656 parts.push(
657 r#"if (.tasks | length) > 0 then " tasks:\n\(.tasks | map(" [\(if .done then "x" else " " end)] \(.text) (line \(.line))") | join("\n"))" else empty end"#.to_owned(),
658 );
659 }
660
661 if map.contains_key("matches") {
663 parts.push(
664 r#"if (.matches | length) > 0 then " matches:\n\(.matches | map(" line \(.line) (\(.section)): \(.text)") | join("\n"))" else empty end"#.to_owned(),
665 );
666 }
667
668 if map.contains_key("links") {
670 parts.push(
671 r#"if (.links | length) > 0 then " links:\n\(.links | map(" \"\(.target)\"\(if .path then " → \"\(.path)\"" else " (unresolved)" end)") | join("\n"))" else empty end"#.to_owned(),
672 );
673 }
674
675 if map.contains_key("backlinks") {
677 parts.push(
678 r#"if (.backlinks | length) > 0 then " backlinks:\n\(.backlinks | map(" \"\(.source)\" line \(.line)\(if .label then ": \(.label)" else "" end)") | join("\n"))" else empty end"#.to_owned(),
679 );
680 }
681
682 parts.join(", ")
683}
684
685fn format_value_as_text(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
691 match value {
692 serde_json::Value::Array(arr) => {
693 let is_file_objects = arr
695 .first()
696 .and_then(|v| v.as_object())
697 .is_some_and(|m| m.contains_key("file") && m.contains_key("modified"));
698 let sep = if is_file_objects { "\n\n" } else { "\n" };
699 arr.iter()
700 .map(|v| format_value_as_text(v, cache))
701 .collect::<Vec<_>>()
702 .join(sep)
703 }
704 serde_json::Value::Object(map) => {
705 let sig = key_signature(map);
706 if let Some(filter) = lookup_filter(&sig)
707 && let Some(output) = apply_jq_filter(filter, value, cache)
708 {
709 return output;
710 }
711 if map.contains_key("file") && map.contains_key("modified") {
713 let filter = build_file_object_filter(map);
714 if let Some(output) = apply_jq_filter(&filter, value, cache) {
715 return output;
716 }
717 }
718 format_object_generic(map, cache)
720 }
721 other => format_scalar(other, cache),
722 }
723}
724
725fn format_object_generic(
727 map: &serde_json::Map<String, serde_json::Value>,
728 cache: &mut JaqFilterCache,
729) -> String {
730 map.iter()
731 .map(|(k, v)| format!("{k}: {}", format_value_as_text(v, cache)))
732 .collect::<Vec<_>>()
733 .join("\n")
734}
735
736fn format_scalar(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
738 match value {
739 serde_json::Value::String(s) => s.clone(),
740 serde_json::Value::Number(n) => n.to_string(),
741 serde_json::Value::Bool(b) => b.to_string(),
742 serde_json::Value::Null => "null".to_owned(),
743 serde_json::Value::Array(arr) => {
744 let items: Vec<String> = arr.iter().map(|v| format_scalar(v, cache)).collect();
745 items.join(", ")
746 }
747 serde_json::Value::Object(_) => format_value_as_text(value, cache),
748 }
749}
750
751#[cfg(test)]
752mod tests {
753 use super::*;
754 use serde_json::json;
755
756 fn jq(filter: &str, val: &serde_json::Value) -> Option<String> {
758 apply_jq_filter(filter, val, &mut JaqFilterCache::new())
759 }
760
761 fn fmt(val: &serde_json::Value) -> String {
762 format_value_as_text(val, &mut JaqFilterCache::new())
763 }
764
765 fn scalar(val: &serde_json::Value) -> String {
766 format_scalar(val, &mut JaqFilterCache::new())
767 }
768
769 #[test]
772 fn format_json_error() {
773 let out = format_error(
774 Format::Json,
775 "file not found",
776 Some("foo/bar"),
777 Some("did you mean foo/bar.md?"),
778 None,
779 );
780 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
781 assert_eq!(parsed["error"], "file not found");
782 assert_eq!(parsed["hint"], "did you mean foo/bar.md?");
783 assert!(parsed.get("cause").is_none());
784 }
785
786 #[test]
787 fn format_text_error() {
788 let out = format_error(Format::Text, "file not found", Some("foo"), None, None);
789 assert!(out.contains("Error: file not found"));
790 assert!(out.contains("path: foo"));
791 }
792
793 #[test]
794 fn format_json_success() {
795 let val = json!({"name": "test", "value": 42});
796 let out = format_success(Format::Json, &val);
797 assert!(out.contains("\"name\": \"test\""));
798 }
799
800 #[test]
803 fn apply_jq_filter_simple() {
804 let val = json!({"name": "hello", "count": 3});
805 let result = jq(r#""\(.name): \(.count)""#, &val);
806 assert_eq!(result.as_deref(), Some("hello: 3"));
807 }
808
809 #[test]
810 fn apply_jq_filter_array_map() {
811 let val = json!(["a", "b", "c"]);
812 let result = jq(".[]", &val);
813 assert_eq!(result.as_deref(), Some("a\nb\nc"));
814 }
815
816 #[test]
817 fn apply_jq_filter_invalid_returns_none() {
818 let val = json!({"x": 1});
819 let result = jq("this is not valid jq %%%", &val);
820 assert!(result.is_none());
821 }
822
823 #[test]
826 fn property_info_filter() {
827 let val = json!({"name": "title", "type": "text", "value": "My Note"});
828 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
829 assert!(out.contains("title"));
830 assert!(out.contains("text"));
831 assert!(out.contains("My Note"));
832 }
833
834 #[test]
835 fn property_info_filter_list_value() {
836 let val = json!({"name": "tags", "type": "list", "value": ["rust", "cli"]});
837 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
838 assert!(out.contains("tags"));
839 assert!(out.contains("list"));
840 assert!(out.contains("[rust, cli]"), "expected [rust, cli]: {out}");
842 assert!(!out.contains("[\"rust\""));
843 }
844
845 #[test]
846 fn property_summary_entry_filter() {
847 let val = json!({"count": 7, "name": "title", "type": "text"});
848 let out = jq(PROPERTY_SUMMARY_ENTRY_FILTER, &val).unwrap();
849 assert!(out.contains("title"));
850 assert!(out.contains("text"));
851 assert!(out.contains("7 files"));
852 }
853
854 #[test]
855 fn tag_summary_filter() {
856 let val = json!({
857 "tags": [{"name": "rust", "count": 3}, {"name": "cli", "count": 1}],
858 "total": 2
859 });
860 let out = jq(TAG_SUMMARY_FILTER, &val).unwrap();
861 assert!(out.contains("2 unique tags"));
862 assert!(out.contains("rust"));
863 assert!(out.contains("3 files"));
864 }
865
866 #[test]
869 fn link_info_target_only_filter() {
870 let val = json!({"target": "broken-link"});
871 let out = jq(LINK_INFO_TARGET_FILTER, &val).unwrap();
872 assert!(out.contains("broken-link"));
873 assert!(out.contains("unresolved"));
874 }
875
876 #[test]
877 fn link_info_with_path_filter() {
878 let val = json!({"path": "note-b.md", "target": "note-b"});
879 let out = jq(LINK_INFO_PATH_FILTER, &val).unwrap();
880 assert!(out.contains("note-b"));
881 assert!(out.contains("note-b.md"));
882 }
883
884 #[test]
887 fn task_count_filter() {
888 let val = json!({"done": 3, "total": 5});
889 let out = jq(TASK_COUNT_FILTER, &val).unwrap();
890 assert_eq!(out, "[3/5]");
891 }
892
893 #[test]
894 fn outline_section_filter() {
895 let val = json!({
896 "code_blocks": [],
897 "heading": "Introduction",
898 "level": 1,
899 "line": 5,
900 "links": ["[[other]]"]
901 });
902 let out = jq(OUTLINE_SECTION_FILTER, &val).unwrap();
903 assert!(out.contains('#'));
904 assert!(out.contains("Introduction"));
905 assert!(out.contains("[[other]]"));
906 }
907
908 #[test]
909 fn outline_section_with_tasks_filter() {
910 let val = json!({
911 "code_blocks": [],
912 "heading": "Tasks",
913 "level": 2,
914 "line": 10,
915 "links": [],
916 "tasks": {"done": 2, "total": 4}
917 });
918 let out = jq(OUTLINE_SECTION_WITH_TASKS_FILTER, &val).unwrap();
919 assert!(out.contains("##"));
920 assert!(out.contains("Tasks"));
921 assert!(out.contains("[2/4]"));
922 }
923
924 #[test]
927 fn find_task_info_filter_done() {
928 let val = json!({
929 "done": true,
930 "line": 42,
931 "section": "Implementation",
932 "status": "x",
933 "text": "Write the tests"
934 });
935 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
936 assert!(out.contains("[x]"));
937 assert!(out.contains("Write the tests"));
938 assert!(out.contains("line 42"));
939 assert!(out.contains("Implementation"));
940 }
941
942 #[test]
943 fn find_task_info_filter_not_done() {
944 let val = json!({
945 "done": false,
946 "line": 7,
947 "section": "Todo",
948 "status": " ",
949 "text": "Review PR"
950 });
951 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
952 assert!(out.contains("[ ]"));
953 assert!(out.contains("Review PR"));
954 assert!(out.contains("line 7"));
955 assert!(out.contains("Todo"));
956 }
957
958 #[test]
959 fn find_task_info_via_format_value_as_text() {
960 let val = json!({
962 "done": true,
963 "line": 5,
964 "section": "Goals",
965 "status": "x",
966 "text": "Ship it"
967 });
968 let out = fmt(&val);
969 assert!(out.contains("[x]"));
970 assert!(out.contains("Ship it"));
971 assert!(
972 !out.contains("done: true"),
973 "should not use generic fallback"
974 );
975 }
976
977 #[test]
980 fn content_match_filter() {
981 let val = json!({
982 "line": 15,
983 "section": "Background",
984 "text": "This is the matching line"
985 });
986 let out = jq(CONTENT_MATCH_FILTER, &val).unwrap();
987 assert!(out.contains("line 15"));
988 assert!(out.contains("Background"));
989 assert!(out.contains("This is the matching line"));
990 }
991
992 #[test]
993 fn content_match_via_format_value_as_text() {
994 let val = json!({
995 "line": 3,
996 "section": "Intro",
997 "text": "hello world"
998 });
999 let out = fmt(&val);
1000 assert!(out.contains("line 3"));
1001 assert!(out.contains("hello world"));
1002 assert!(!out.contains("line: 3"), "should not use generic fallback");
1003 }
1004
1005 #[test]
1008 fn property_value_mutation_filter_with_modified() {
1009 let val = json!({
1012 "modified": ["note-a.md", "note-b.md"],
1013 "property": "status",
1014 "scanned": 2,
1015 "skipped": [],
1016 "total": 2,
1017 "value": "done"
1018 });
1019 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1020 assert!(out.contains("status=done"));
1021 assert!(out.contains("2/2 modified"));
1022 assert!(
1023 !out.contains("scanned"),
1024 "no scanned suffix when scanned == total"
1025 );
1026 assert!(out.contains("note-a.md"));
1027 assert!(out.contains("note-b.md"));
1028 }
1029
1030 #[test]
1031 fn property_value_mutation_filter_all_skipped() {
1032 let val = json!({
1033 "modified": [],
1034 "property": "priority",
1035 "scanned": 1,
1036 "skipped": ["note-a.md"],
1037 "total": 1,
1038 "value": "high"
1039 });
1040 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1041 assert!(out.contains("priority=high"));
1042 assert!(out.contains("0/1 modified"));
1043 assert!(!out.contains("note-a.md"));
1045 }
1046
1047 #[test]
1048 fn property_value_mutation_filter_with_where_filter() {
1049 let val = json!({
1051 "modified": ["note-a.md"],
1052 "property": "status",
1053 "scanned": 5,
1054 "skipped": [],
1055 "total": 1,
1056 "value": "done"
1057 });
1058 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1059 assert!(out.contains("status=done"));
1060 assert!(out.contains("1/1 modified"));
1061 assert!(out.contains("(5 scanned)"));
1062 }
1063
1064 #[test]
1065 fn property_value_mutation_via_format_value_as_text() {
1066 let val = json!({
1067 "dry_run": false,
1068 "modified": ["notes/a.md"],
1069 "property": "status",
1070 "scanned": 1,
1071 "skipped": [],
1072 "total": 1,
1073 "value": "done"
1074 });
1075 let out = fmt(&val);
1076 assert!(out.contains("status=done"));
1077 assert!(
1078 !out.contains("modified: "),
1079 "should not use generic fallback"
1080 );
1081 }
1082
1083 #[test]
1084 fn property_mutation_filter_no_value() {
1085 let val = json!({
1087 "dry_run": false,
1088 "modified": ["note.md"],
1089 "property": "draft",
1090 "scanned": 1,
1091 "skipped": [],
1092 "total": 1
1093 });
1094 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1095 assert!(out.contains("draft"));
1096 assert!(out.contains("1/1 modified"));
1097 assert!(
1098 !out.contains("scanned"),
1099 "no scanned suffix when scanned == total"
1100 );
1101 assert!(out.contains("note.md"));
1102 }
1103
1104 #[test]
1105 fn property_mutation_filter_no_value_with_where_filter() {
1106 let val = json!({
1108 "dry_run": false,
1109 "modified": ["note.md"],
1110 "property": "draft",
1111 "scanned": 7,
1112 "skipped": [],
1113 "total": 1
1114 });
1115 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1116 assert!(out.contains("draft"));
1117 assert!(out.contains("1/1 modified"));
1118 assert!(out.contains("(7 scanned)"));
1119 }
1120
1121 #[test]
1122 fn tag_mutation_filter_with_modified() {
1123 let val = json!({
1125 "dry_run": false,
1126 "modified": ["a.md", "b.md"],
1127 "scanned": 3,
1128 "skipped": ["c.md"],
1129 "tag": "rust",
1130 "total": 3
1131 });
1132 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1133 assert!(out.contains("rust"));
1134 assert!(out.contains("2/3 modified"));
1135 assert!(
1136 !out.contains("scanned"),
1137 "no scanned suffix when scanned == total"
1138 );
1139 assert!(out.contains("a.md"));
1140 assert!(out.contains("b.md"));
1141 assert!(!out.contains("c.md"));
1142 }
1143
1144 #[test]
1145 fn tag_mutation_filter_with_where_filter() {
1146 let val = json!({
1148 "dry_run": false,
1149 "modified": ["a.md"],
1150 "scanned": 10,
1151 "skipped": [],
1152 "tag": "rust",
1153 "total": 1
1154 });
1155 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1156 assert!(out.contains("rust"));
1157 assert!(out.contains("1/1 modified"));
1158 assert!(out.contains("(10 scanned)"));
1159 }
1160
1161 #[test]
1162 fn tag_mutation_via_format_value_as_text() {
1163 let val = json!({
1164 "dry_run": false,
1165 "modified": [],
1166 "scanned": 1,
1167 "skipped": ["note.md"],
1168 "tag": "cli",
1169 "total": 1
1170 });
1171 let out = fmt(&val);
1172 assert!(out.contains("cli"));
1173 assert!(!out.contains("tag: cli"), "should not use generic fallback");
1174 }
1175
1176 #[test]
1179 fn property_value_mutation_dry_run_prefix() {
1180 let val = json!({
1181 "dry_run": true,
1182 "modified": ["note.md"],
1183 "property": "status",
1184 "scanned": 1,
1185 "skipped": [],
1186 "total": 1,
1187 "value": "done"
1188 });
1189 let out = fmt(&val);
1190 assert!(
1191 out.contains("[dry-run] status=done"),
1192 "dry-run prefix missing: {out}"
1193 );
1194 }
1195
1196 #[test]
1197 fn tag_mutation_dry_run_prefix() {
1198 let val = json!({
1199 "dry_run": true,
1200 "modified": ["note.md"],
1201 "scanned": 1,
1202 "skipped": [],
1203 "tag": "rust",
1204 "total": 1
1205 });
1206 let out = fmt(&val);
1207 assert!(
1208 out.contains("[dry-run] rust"),
1209 "dry-run prefix missing: {out}"
1210 );
1211 }
1212
1213 #[test]
1214 fn property_value_mutation_no_dry_run_prefix() {
1215 let val = json!({
1216 "dry_run": false,
1217 "modified": ["note.md"],
1218 "property": "status",
1219 "scanned": 1,
1220 "skipped": [],
1221 "total": 1,
1222 "value": "done"
1223 });
1224 let out = fmt(&val);
1225 assert!(
1226 !out.contains("[dry-run]"),
1227 "should not have dry-run prefix: {out}"
1228 );
1229 }
1230
1231 #[test]
1234 fn build_file_object_filter_minimal() {
1235 let map: serde_json::Map<String, serde_json::Value> =
1237 serde_json::from_str(r#"{"file": "notes/foo.md", "modified": "2024-01-01"}"#).unwrap();
1238 let filter = build_file_object_filter(&map);
1239 let val = json!({"file": "notes/foo.md", "modified": "2024-01-01"});
1240 let out = jq(&filter, &val).unwrap();
1241 assert!(out.contains("notes/foo.md"));
1242 assert!(out.contains("2024-01-01"));
1243 }
1244
1245 #[test]
1246 fn build_file_object_filter_with_tags() {
1247 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1248 r#"{"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]}"#,
1249 )
1250 .unwrap();
1251 let filter = build_file_object_filter(&map);
1252 let val = json!({"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]});
1253 let out = jq(&filter, &val).unwrap();
1254 assert!(out.contains("foo.md"));
1255 assert!(out.contains("tags: [rust, cli]"));
1256 }
1257
1258 #[test]
1259 fn build_file_object_filter_with_properties() {
1260 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1261 r#"{"file": "foo.md", "modified": "2024-01-01", "properties": {"status": "done"}}"#,
1262 )
1263 .unwrap();
1264 let filter = build_file_object_filter(&map);
1265 let val = json!({
1266 "file": "foo.md",
1267 "modified": "2024-01-01",
1268 "properties": {"status": "done"}
1269 });
1270 let out = jq(&filter, &val).unwrap();
1271 assert!(out.contains("foo.md"));
1272 assert!(out.contains("properties:"));
1273 assert!(out.contains("status: done"));
1274 }
1275
1276 #[test]
1277 fn build_file_object_filter_with_tasks() {
1278 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1279 r#"{"file": "foo.md", "modified": "2024-01-01", "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]}"#,
1280 )
1281 .unwrap();
1282 let filter = build_file_object_filter(&map);
1283 let val = json!({
1284 "file": "foo.md",
1285 "modified": "2024-01-01",
1286 "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]
1287 });
1288 let out = jq(&filter, &val).unwrap();
1289 assert!(out.contains("foo.md"));
1290 assert!(out.contains("tasks:"));
1291 assert!(out.contains("[x] Ship it"));
1292 assert!(out.contains("line 5"));
1293 }
1294
1295 #[test]
1296 fn build_file_object_filter_with_sections() {
1297 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1298 r#"{"file": "foo.md", "modified": "2024-01-01", "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]}"#,
1299 )
1300 .unwrap();
1301 let filter = build_file_object_filter(&map);
1302 let val = json!({
1303 "file": "foo.md",
1304 "modified": "2024-01-01",
1305 "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]
1306 });
1307 let out = jq(&filter, &val).unwrap();
1308 assert!(out.contains("foo.md"));
1309 assert!(out.contains("sections:"));
1310 assert!(out.contains("# Intro"));
1311 }
1312
1313 #[test]
1314 fn build_file_object_filter_with_matches() {
1315 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1316 r#"{"file": "foo.md", "modified": "2024-01-01", "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]}"#,
1317 )
1318 .unwrap();
1319 let filter = build_file_object_filter(&map);
1320 let val = json!({
1321 "file": "foo.md",
1322 "modified": "2024-01-01",
1323 "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]
1324 });
1325 let out = jq(&filter, &val).unwrap();
1326 assert!(out.contains("foo.md"));
1327 assert!(out.contains("matches:"));
1328 assert!(out.contains("line 3 (Intro): hello world"));
1329 }
1330
1331 #[test]
1332 fn build_file_object_filter_with_links() {
1333 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1334 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "bar", "path": "bar.md"}]}"#,
1335 )
1336 .unwrap();
1337 let filter = build_file_object_filter(&map);
1338 let val = json!({
1339 "file": "foo.md",
1340 "modified": "2024-01-01",
1341 "links": [{"target": "bar", "path": "bar.md"}]
1342 });
1343 let out = jq(&filter, &val).unwrap();
1344 assert!(out.contains("foo.md"));
1345 assert!(out.contains("links:"));
1346 assert!(out.contains(r#""bar" → "bar.md""#));
1347 }
1348
1349 #[test]
1350 fn build_file_object_filter_unresolved_link() {
1351 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1352 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "missing"}]}"#,
1353 )
1354 .unwrap();
1355 let filter = build_file_object_filter(&map);
1356 let val = json!({
1357 "file": "foo.md",
1358 "modified": "2024-01-01",
1359 "links": [{"target": "missing"}]
1360 });
1361 let out = jq(&filter, &val).unwrap();
1362 assert!(out.contains(r#""missing" (unresolved)"#));
1363 }
1364
1365 #[test]
1368 fn file_object_text_rendering_minimal() {
1369 let val = json!({"file": "notes/foo.md", "modified": "2024-01-15"});
1370 let out = fmt(&val);
1371 assert!(out.contains("notes/foo.md"));
1372 assert!(out.contains("2024-01-15"));
1373 assert!(!out.contains("file: notes/foo.md"));
1375 }
1376
1377 #[test]
1378 fn file_object_text_rendering_full() {
1379 let val = json!({
1380 "file": "notes/project.md",
1381 "modified": "2024-03-01",
1382 "tags": ["rust", "work"],
1383 "properties": {"status": "active"},
1384 "tasks": [
1385 {"done": false, "line": 10, "section": "Todo", "status": " ", "text": "Fix bug"},
1386 {"done": true, "line": 20, "section": "Done", "status": "x", "text": "Write docs"}
1387 ]
1388 });
1389 let out = fmt(&val);
1390 assert!(out.contains("notes/project.md"));
1391 assert!(out.contains("properties:"));
1392 assert!(out.contains("status: active"));
1393 assert!(out.contains("tags: [rust, work]"));
1394 assert!(out.contains("tasks:"));
1395 assert!(out.contains("[ ] Fix bug"));
1396 assert!(out.contains("[x] Write docs"));
1397 }
1398
1399 #[test]
1402 fn array_of_file_objects_uses_blank_line_separator() {
1403 let val = json!([
1404 {"file": "a.md", "modified": "2024-01-01"},
1405 {"file": "b.md", "modified": "2024-01-02"}
1406 ]);
1407 let out = fmt(&val);
1408 assert!(out.contains("a.md"));
1409 assert!(out.contains("b.md"));
1410 assert!(
1412 out.contains("\n\n"),
1413 "expected blank-line separator between file objects"
1414 );
1415 }
1416
1417 #[test]
1418 fn array_of_non_file_objects_uses_single_newline() {
1419 let val = json!([
1420 {"count": 1, "name": "status", "type": "text"},
1421 {"count": 3, "name": "title", "type": "text"}
1422 ]);
1423 let out = fmt(&val);
1424 assert!(out.contains("status"));
1425 assert!(out.contains("title"));
1426 assert!(
1428 !out.contains("\n\n"),
1429 "non-file-objects should use single newline"
1430 );
1431 }
1432
1433 #[test]
1436 fn format_scalar_delegates_nested_objects() {
1437 let inner = json!({"count": 2, "name": "status", "type": "text"});
1440 let out = scalar(&inner);
1441 assert!(
1443 !out.contains("count=2"),
1444 "should delegate to format_value_as_text"
1445 );
1446 assert!(out.contains("status"));
1448 assert!(out.contains("2 files"));
1449 }
1450
1451 #[test]
1454 fn format_value_as_text_uses_filter_for_known_shape() {
1455 let val = json!({"count": 3, "name": "status", "type": "text"});
1457 let out = fmt(&val);
1458 assert!(out.contains("status"));
1459 assert!(out.contains("3 files"));
1460 assert!(!out.contains("count: 3"));
1462 }
1463
1464 #[test]
1465 fn format_value_as_text_falls_back_for_unknown_shape() {
1466 let val = json!({"foo": "bar", "baz": 42});
1467 let out = fmt(&val);
1468 assert!(out.contains("foo: bar") || out.contains("baz: 42"));
1470 }
1471
1472 #[test]
1473 fn mv_result_filter_applied() {
1474 let val = json!({
1475 "dry_run": false,
1476 "from": "sub/b.md",
1477 "to": "archive/b.md",
1478 "total_files_updated": 1,
1479 "total_links_updated": 1,
1480 "updated_files": [
1481 {
1482 "file": "a.md",
1483 "replacements": [
1484 {"old_text": "[[sub/b]]", "new_text": "[[archive/b]]", "line": 1}
1485 ]
1486 }
1487 ]
1488 });
1489 let sig = {
1491 let map = val.as_object().unwrap();
1492 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
1493 keys.sort_unstable();
1494 keys.join(",")
1495 };
1496 assert_eq!(
1497 sig,
1498 "dry_run,from,to,total_files_updated,total_links_updated,updated_files"
1499 );
1500 let filter_result = apply_jq_filter_result(MV_RESULT_FILTER, &val);
1502 assert!(filter_result.is_ok(), "filter error: {filter_result:?}");
1503 let out = filter_result.unwrap();
1504 assert!(out.contains("Moved sub/b.md"), "out: {out}");
1505 assert!(out.contains("archive/b.md"), "out: {out}");
1506 assert!(out.contains("[[sub/b]]"), "out: {out}");
1507 assert!(out.contains("[[archive/b]]"), "out: {out}");
1508 let found_filter =
1510 lookup_filter("dry_run,from,to,total_files_updated,total_links_updated,updated_files");
1511 assert!(
1512 found_filter.is_some(),
1513 "lookup_filter returned None for MvResult shape"
1514 );
1515 let formatted = fmt(&val);
1517 assert!(
1518 formatted.contains("Moved sub/b.md"),
1519 "formatted: {formatted}"
1520 );
1521 }
1522
1523 #[test]
1524 fn format_value_as_text_array_of_typed_objects() {
1525 let val = json!([
1526 {"path": "a.md", "tags": ["rust"]},
1527 {"path": "b.md", "tags": ["cli"]}
1528 ]);
1529 let out = fmt(&val);
1530 assert!(out.contains("a.md"));
1531 assert!(out.contains("b.md"));
1532 assert!(out.contains("rust"));
1533 assert!(out.contains("cli"));
1534 }
1535}