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).unwrap_or_default(),
117 Format::Text => {
118 let mut cache = JaqFilterCache::new();
119 format_value_as_text(value, &mut cache)
120 }
121 }
122}
123
124#[must_use]
129pub fn format_output<T: Serialize>(format: Format, value: &T) -> String {
130 let json = serde_json::to_value(value).expect("derived Serialize impl should not fail");
131 format_success(format, &json)
132}
133
134#[must_use]
139pub fn build_envelope_value(
140 value: &serde_json::Value,
141 total: Option<u64>,
142 hints: &[crate::hints::Hint],
143) -> serde_json::Value {
144 let hints_json: Vec<serde_json::Value> = hints
145 .iter()
146 .map(|h| serde_json::json!({"description": &h.description, "cmd": &h.cmd}))
147 .collect();
148 let mut envelope = serde_json::json!({
149 "results": value,
150 "hints": hints_json,
151 });
152 if let Some(t) = total {
153 envelope["total"] = serde_json::json!(t);
154 }
155 envelope
156}
157
158#[must_use]
163pub fn format_envelope(
164 format: Format,
165 value: &serde_json::Value,
166 total: Option<u64>,
167 hints: &[crate::hints::Hint],
168) -> String {
169 match format {
170 Format::Json => {
171 let envelope = build_envelope_value(value, total, hints);
172 serde_json::to_string_pretty(&envelope).unwrap_or_default()
173 }
174 Format::Text => {
175 let mut cache = JaqFilterCache::new();
176 let mut text = format_results_as_text(value, total, &mut cache);
177 if !hints.is_empty() {
178 text.push('\n');
179 for hint in hints {
180 text.push_str("\n -> ");
181 text.push_str(&hint.cmd);
182 text.push_str(" # ");
183 text.push_str(&hint.description);
184 }
185 }
186 text
187 }
188 }
189}
190
191fn format_results_as_text(
196 results: &serde_json::Value,
197 total: Option<u64>,
198 cache: &mut JaqFilterCache,
199) -> String {
200 if let (Some(total), serde_json::Value::Array(arr)) = (total, results) {
203 let is_tag_array = !arr.is_empty()
204 && arr.iter().all(|v| {
205 v.as_object().is_some_and(|m| {
206 m.contains_key("count") && m.contains_key("name") && m.len() == 2
207 })
208 });
209 if is_tag_array {
210 let tag_label = if total == 1 { "tag" } else { "tags" };
211 let header = format!("{total} unique {tag_label}");
212 let entries = format_value_as_text(results, cache);
213 return if entries.is_empty() {
214 header
215 } else {
216 format!("{header}\n{entries}")
217 };
218 }
219 }
220
221 let text = format_value_as_text(results, cache);
222 if let Some(total) = total {
223 let shown = match results {
224 serde_json::Value::Array(arr) => arr.len() as u64,
225 _ => return text,
226 };
227 if shown < total {
228 return format!("{text}\nshowing {shown} of {total} matches");
229 }
230 }
231 text
232}
233
234#[must_use]
236pub fn format_error(
237 format: Format,
238 error: &str,
239 path: Option<&str>,
240 hint: Option<&str>,
241 cause: Option<&str>,
242) -> String {
243 match format {
244 Format::Json => {
245 let mut obj = json!({"error": error});
246 if let Some(p) = path {
247 obj["path"] = json!(p);
248 }
249 if let Some(h) = hint {
250 obj["hint"] = json!(h);
251 }
252 if let Some(c) = cause {
253 obj["cause"] = json!(c);
254 }
255 serde_json::to_string_pretty(&obj).unwrap_or_default()
256 }
257 Format::Text => {
258 let mut msg = format!("Error: {error}");
259 if let Some(p) = path {
260 let _ = write!(msg, "\n path: {p}");
261 }
262 if let Some(h) = hint {
263 let _ = write!(msg, "\n hint: {h}");
264 }
265 if let Some(c) = cause {
266 let _ = write!(msg, "\n cause: {c}");
267 }
268 msg
269 }
270 }
271}
272
273const PROPERTY_INFO_FILTER: &str = r#""\(.name) (\(.type)): \(if (.value | type) == "array" then "[" + (.value | join(", ")) + "]" else .value end)""#;
280
281const PROPERTY_SUMMARY_ENTRY_FILTER: &str =
283 r#""\(.name)\t\(.type)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;
284
285const 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"))""#;
287
288const TAG_SUMMARY_ENTRY_FILTER: &str =
290 r#""\(.name)\t\(.count) \(if .count == 1 then "file" else "files" end)""#;
291
292const LINK_INFO_TARGET_FILTER: &str = r#"" \"\(.target)\" (unresolved)""#;
295
296const LINK_INFO_PATH_FILTER: &str = r#"" \"\(.target)\" → \"\(.path)\"""#;
299
300const LINK_INFO_LABEL_FILTER: &str = r#"" \"\(.target)\" (unresolved) [\(.label)]""#;
303
304const LINK_INFO_FULL_FILTER: &str = r#"" \"\(.target)\" → \"\(.path)\" [\(.label)]""#;
307
308const TASK_COUNT_FILTER: &str = r#""[\(.done)/\(.total)]""#;
310
311const OUTLINE_SECTION_FILTER: &str = r##""\("#" * .level) \(.heading // "(pre-heading)")\(if (.links | length) > 0 then "\n\(.links | map(" → \"\(.)\"") | join("\n"))" else "" end)""##;
313
314const 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)""##;
316
317const TASK_INFO_FILTER: &str =
319 r#""line \(.line): [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;
320
321const TASK_READ_RESULT_FILTER: &str =
323 r#""\"\(.file)\":\(.line) [\(.status)] \(.text)\(if .done then " (done)" else "" end)""#;
324
325const 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)""#;
327
328const FIND_TASK_INFO_FILTER: &str =
331 r#"" [\(if .done then "x" else " " end)] \(.text) (line \(.line), \(.section))""#;
332
333const CONTENT_MATCH_FILTER: &str = r#"" line \(.line) (\(.section)): \(.text)""#;
336
337const 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)""#;
343
344const 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)""#;
350
351const 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)""#;
357
358const 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"#;
362
363const 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)""#;
366
367const 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)""#;
370
371fn key_signature(map: &serde_json::Map<String, serde_json::Value>) -> String {
377 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
378 keys.sort_unstable();
379 keys.join(",")
380}
381
382fn lookup_filter(key_sig: &str) -> Option<&'static str> {
386 match key_sig {
387 "name,type,value" => Some(PROPERTY_INFO_FILTER),
389 "count,name,type" => Some(PROPERTY_SUMMARY_ENTRY_FILTER),
391 "tags,total" => Some(TAG_SUMMARY_FILTER),
393 "count,name" => Some(TAG_SUMMARY_ENTRY_FILTER),
395 "target" => Some(LINK_INFO_TARGET_FILTER),
397 "path,target" => Some(LINK_INFO_PATH_FILTER),
398 "label,target" => Some(LINK_INFO_LABEL_FILTER),
399 "label,path,target" => Some(LINK_INFO_FULL_FILTER),
400 "done,total" => Some(TASK_COUNT_FILTER),
402 "code_blocks,heading,level,line,links" => Some(OUTLINE_SECTION_FILTER),
404 "code_blocks,heading,level,line,links,tasks" => Some(OUTLINE_SECTION_WITH_TASKS_FILTER),
405 "done,line,status,text" => Some(TASK_INFO_FILTER),
407 "done,line,section,status,text" => Some(FIND_TASK_INFO_FILTER),
409 "line,section,text" => Some(CONTENT_MATCH_FILTER),
411 "done,file,line,status,text" => Some(TASK_READ_RESULT_FILTER),
413 "dead_ends,files,links,orphans,properties,recent_files,status,tags,tasks" => {
415 Some(VAULT_SUMMARY_FILTER)
416 }
417 "dry_run,modified,property,scanned,skipped,total,value" => {
420 Some(PROPERTY_VALUE_MUTATION_FILTER)
421 }
422 "dry_run,modified,property,scanned,skipped,total" => Some(PROPERTY_MUTATION_FILTER),
424 "dry_run,modified,scanned,skipped,tag,total" => Some(TAG_MUTATION_FILTER),
426 "backlinks,file" => Some(BACKLINKS_RESULT_FILTER),
428 "applied,broken,fixable,fixes,ignored,unfixable,unfixable_links" => Some(LINKS_FIX_FILTER),
430 "dry_run,from,to,total_files_updated,total_links_updated,updated_files" => {
432 Some(MV_RESULT_FILTER)
433 }
434 _ => None,
435 }
436}
437
438fn apply_jq_filter(
448 filter_code: &str,
449 value: &serde_json::Value,
450 cache: &mut JaqFilterCache,
451) -> Option<String> {
452 run_jq_filter_cached(filter_code, value, cache).ok()
453}
454
455pub fn apply_jq_filter_result(
463 filter_code: &str,
464 value: &serde_json::Value,
465) -> Result<String, String> {
466 let filter = compile_jq_filter(filter_code)?;
467 execute_jq_filter(&filter, value)
468}
469
470fn format_load_errors(errs: &load::Errors<&str, ()>) -> String {
475 for (_file, err) in errs {
478 match err {
479 load::Error::Io(ios) => {
480 if let Some((_path, msg)) = ios.first() {
481 return format!("jq filter error (IO): {msg}");
482 }
483 }
484 load::Error::Lex(lex_errs) => {
485 if let Some((expect, span)) = lex_errs.first() {
486 return format!(
487 "jq filter syntax error: expected {} near {:?}",
488 expect.as_str(),
489 span
490 );
491 }
492 }
493 load::Error::Parse(parse_errs) => {
494 if let Some((expect, _token)) = parse_errs.first() {
495 return format!("jq filter parse error: expected {}", expect.as_str());
496 }
497 }
498 }
499 }
500 "jq filter error: invalid filter syntax".to_owned()
501}
502
503fn compile_jq_filter(filter_code: &str) -> Result<jaq_core::compile::Filter<Native<D>>, String> {
508 let program = File {
509 code: filter_code,
510 path: (),
511 };
512 let defs = jaq_core::defs()
513 .chain(jaq_std::defs())
514 .chain(jaq_json::defs());
515 let loader = Loader::new(defs);
516 let arena = Arena::default();
517
518 let modules = loader
519 .load(&arena, program)
520 .map_err(|errs| format_load_errors(&errs))?;
521
522 let funs = jaq_core::funs::<D>()
523 .chain(jaq_std::funs::<D>())
524 .chain(jaq_json::funs::<D>());
525 Compiler::default()
526 .with_funs(funs)
527 .compile(modules)
528 .map_err(|errs| {
529 let first = errs.iter().flat_map(|(_file, undefs)| undefs.iter()).next();
532 if let Some((name, undef)) = first {
533 format!("jq filter error: undefined {} {:?}", undef.as_str(), name)
534 } else {
535 "jq filter error: compilation failed".to_owned()
536 }
537 })
538}
539
540fn execute_jq_filter(
542 filter: &jaq_core::compile::Filter<Native<D>>,
543 value: &serde_json::Value,
544) -> Result<String, String> {
545 let input: Val = serde_json::from_value(value.clone())
546 .map_err(|e| format!("jq input conversion error: {e}"))?;
547 let ctx = Ctx::<D>::new(&filter.lut, Vars::new([]));
548
549 let mut parts = Vec::new();
550 for result in filter.id.run((ctx, input)).map(jaq_core::unwrap_valr) {
551 match result {
552 Ok(val) => {
553 let s = match val {
554 Val::TStr(ref s) | Val::BStr(ref s) => match std::str::from_utf8(s) {
555 Ok(valid) => valid.to_owned(),
556 Err(_) => String::from_utf8_lossy(s).into_owned(),
557 },
558 other => other.to_string(),
561 };
562 parts.push(s);
563 }
564 Err(e) => return Err(format!("jq runtime error: {e}")),
565 }
566 }
567
568 Ok(parts.join("\n"))
569}
570
571fn run_jq_filter_cached(
573 filter_code: &str,
574 value: &serde_json::Value,
575 cache: &mut JaqFilterCache,
576) -> Result<String, String> {
577 if let Some(filter) = cache.get(filter_code) {
578 return execute_jq_filter(filter, value);
579 }
580 let compiled = compile_jq_filter(filter_code)?;
581 let filter = cache.entry(filter_code.to_owned()).or_insert(compiled);
582 execute_jq_filter(filter, value)
583}
584
585fn build_file_object_filter(map: &serde_json::Map<String, serde_json::Value>) -> String {
602 let mut parts = vec![r#""\"\(.file)\" (\(.modified))""#.to_owned()];
604
605 if map.contains_key("title") {
607 parts.push(r#"" title: \(if .title != null then .title else "(none)" end)""#.to_owned());
608 }
609
610 if map.contains_key("properties") {
612 parts.push(
613 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(),
614 );
615 }
616
617 if map.contains_key("properties_typed") {
619 parts.push(
620 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(),
621 );
622 }
623
624 if map.contains_key("tags") {
626 parts.push(
627 r#"if (.tags | length) > 0 then " tags: [\(.tags | join(", "))]" else empty end"#
628 .to_owned(),
629 );
630 }
631
632 if map.contains_key("sections") {
635 parts.push(
636 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(),
637 );
638 }
639
640 if map.contains_key("tasks") {
642 parts.push(
643 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(),
644 );
645 }
646
647 if map.contains_key("matches") {
649 parts.push(
650 r#"if (.matches | length) > 0 then " matches:\n\(.matches | map(" line \(.line) (\(.section)): \(.text)") | join("\n"))" else empty end"#.to_owned(),
651 );
652 }
653
654 if map.contains_key("links") {
656 parts.push(
657 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(),
658 );
659 }
660
661 if map.contains_key("backlinks") {
663 parts.push(
664 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(),
665 );
666 }
667
668 parts.join(", ")
669}
670
671fn format_value_as_text(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
677 match value {
678 serde_json::Value::Array(arr) => {
679 let is_file_objects = arr
681 .first()
682 .and_then(|v| v.as_object())
683 .is_some_and(|m| m.contains_key("file") && m.contains_key("modified"));
684 let sep = if is_file_objects { "\n\n" } else { "\n" };
685 arr.iter()
686 .map(|v| format_value_as_text(v, cache))
687 .collect::<Vec<_>>()
688 .join(sep)
689 }
690 serde_json::Value::Object(map) => {
691 let sig = key_signature(map);
692 if let Some(filter) = lookup_filter(&sig)
693 && let Some(output) = apply_jq_filter(filter, value, cache)
694 {
695 return output;
696 }
697 if map.contains_key("file") && map.contains_key("modified") {
699 let filter = build_file_object_filter(map);
700 if let Some(output) = apply_jq_filter(&filter, value, cache) {
701 return output;
702 }
703 }
704 format_object_generic(map, cache)
706 }
707 other => format_scalar(other, cache),
708 }
709}
710
711fn format_object_generic(
713 map: &serde_json::Map<String, serde_json::Value>,
714 cache: &mut JaqFilterCache,
715) -> String {
716 map.iter()
717 .map(|(k, v)| format!("{k}: {}", format_value_as_text(v, cache)))
718 .collect::<Vec<_>>()
719 .join("\n")
720}
721
722fn format_scalar(value: &serde_json::Value, cache: &mut JaqFilterCache) -> String {
724 match value {
725 serde_json::Value::String(s) => s.clone(),
726 serde_json::Value::Number(n) => n.to_string(),
727 serde_json::Value::Bool(b) => b.to_string(),
728 serde_json::Value::Null => "null".to_owned(),
729 serde_json::Value::Array(arr) => {
730 let items: Vec<String> = arr.iter().map(|v| format_scalar(v, cache)).collect();
731 items.join(", ")
732 }
733 serde_json::Value::Object(_) => format_value_as_text(value, cache),
734 }
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740 use serde_json::json;
741
742 fn jq(filter: &str, val: &serde_json::Value) -> Option<String> {
744 apply_jq_filter(filter, val, &mut JaqFilterCache::new())
745 }
746
747 fn fmt(val: &serde_json::Value) -> String {
748 format_value_as_text(val, &mut JaqFilterCache::new())
749 }
750
751 fn scalar(val: &serde_json::Value) -> String {
752 format_scalar(val, &mut JaqFilterCache::new())
753 }
754
755 #[test]
758 fn format_json_error() {
759 let out = format_error(
760 Format::Json,
761 "file not found",
762 Some("foo/bar"),
763 Some("did you mean foo/bar.md?"),
764 None,
765 );
766 let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
767 assert_eq!(parsed["error"], "file not found");
768 assert_eq!(parsed["hint"], "did you mean foo/bar.md?");
769 assert!(parsed.get("cause").is_none());
770 }
771
772 #[test]
773 fn format_text_error() {
774 let out = format_error(Format::Text, "file not found", Some("foo"), None, None);
775 assert!(out.contains("Error: file not found"));
776 assert!(out.contains("path: foo"));
777 }
778
779 #[test]
780 fn format_json_success() {
781 let val = json!({"name": "test", "value": 42});
782 let out = format_success(Format::Json, &val);
783 assert!(out.contains("\"name\": \"test\""));
784 }
785
786 #[test]
789 fn apply_jq_filter_simple() {
790 let val = json!({"name": "hello", "count": 3});
791 let result = jq(r#""\(.name): \(.count)""#, &val);
792 assert_eq!(result.as_deref(), Some("hello: 3"));
793 }
794
795 #[test]
796 fn apply_jq_filter_array_map() {
797 let val = json!(["a", "b", "c"]);
798 let result = jq(".[]", &val);
799 assert_eq!(result.as_deref(), Some("a\nb\nc"));
800 }
801
802 #[test]
803 fn apply_jq_filter_invalid_returns_none() {
804 let val = json!({"x": 1});
805 let result = jq("this is not valid jq %%%", &val);
806 assert!(result.is_none());
807 }
808
809 #[test]
812 fn property_info_filter() {
813 let val = json!({"name": "title", "type": "text", "value": "My Note"});
814 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
815 assert!(out.contains("title"));
816 assert!(out.contains("text"));
817 assert!(out.contains("My Note"));
818 }
819
820 #[test]
821 fn property_info_filter_list_value() {
822 let val = json!({"name": "tags", "type": "list", "value": ["rust", "cli"]});
823 let out = jq(PROPERTY_INFO_FILTER, &val).unwrap();
824 assert!(out.contains("tags"));
825 assert!(out.contains("list"));
826 assert!(out.contains("[rust, cli]"), "expected [rust, cli]: {out}");
828 assert!(!out.contains("[\"rust\""));
829 }
830
831 #[test]
832 fn property_summary_entry_filter() {
833 let val = json!({"count": 7, "name": "title", "type": "text"});
834 let out = jq(PROPERTY_SUMMARY_ENTRY_FILTER, &val).unwrap();
835 assert!(out.contains("title"));
836 assert!(out.contains("text"));
837 assert!(out.contains("7 files"));
838 }
839
840 #[test]
841 fn tag_summary_filter() {
842 let val = json!({
843 "tags": [{"name": "rust", "count": 3}, {"name": "cli", "count": 1}],
844 "total": 2
845 });
846 let out = jq(TAG_SUMMARY_FILTER, &val).unwrap();
847 assert!(out.contains("2 unique tags"));
848 assert!(out.contains("rust"));
849 assert!(out.contains("3 files"));
850 }
851
852 #[test]
855 fn link_info_target_only_filter() {
856 let val = json!({"target": "broken-link"});
857 let out = jq(LINK_INFO_TARGET_FILTER, &val).unwrap();
858 assert!(out.contains("broken-link"));
859 assert!(out.contains("unresolved"));
860 }
861
862 #[test]
863 fn link_info_with_path_filter() {
864 let val = json!({"path": "note-b.md", "target": "note-b"});
865 let out = jq(LINK_INFO_PATH_FILTER, &val).unwrap();
866 assert!(out.contains("note-b"));
867 assert!(out.contains("note-b.md"));
868 }
869
870 #[test]
873 fn task_count_filter() {
874 let val = json!({"done": 3, "total": 5});
875 let out = jq(TASK_COUNT_FILTER, &val).unwrap();
876 assert_eq!(out, "[3/5]");
877 }
878
879 #[test]
880 fn outline_section_filter() {
881 let val = json!({
882 "code_blocks": [],
883 "heading": "Introduction",
884 "level": 1,
885 "line": 5,
886 "links": ["[[other]]"]
887 });
888 let out = jq(OUTLINE_SECTION_FILTER, &val).unwrap();
889 assert!(out.contains('#'));
890 assert!(out.contains("Introduction"));
891 assert!(out.contains("[[other]]"));
892 }
893
894 #[test]
895 fn outline_section_with_tasks_filter() {
896 let val = json!({
897 "code_blocks": [],
898 "heading": "Tasks",
899 "level": 2,
900 "line": 10,
901 "links": [],
902 "tasks": {"done": 2, "total": 4}
903 });
904 let out = jq(OUTLINE_SECTION_WITH_TASKS_FILTER, &val).unwrap();
905 assert!(out.contains("##"));
906 assert!(out.contains("Tasks"));
907 assert!(out.contains("[2/4]"));
908 }
909
910 #[test]
913 fn find_task_info_filter_done() {
914 let val = json!({
915 "done": true,
916 "line": 42,
917 "section": "Implementation",
918 "status": "x",
919 "text": "Write the tests"
920 });
921 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
922 assert!(out.contains("[x]"));
923 assert!(out.contains("Write the tests"));
924 assert!(out.contains("line 42"));
925 assert!(out.contains("Implementation"));
926 }
927
928 #[test]
929 fn find_task_info_filter_not_done() {
930 let val = json!({
931 "done": false,
932 "line": 7,
933 "section": "Todo",
934 "status": " ",
935 "text": "Review PR"
936 });
937 let out = jq(FIND_TASK_INFO_FILTER, &val).unwrap();
938 assert!(out.contains("[ ]"));
939 assert!(out.contains("Review PR"));
940 assert!(out.contains("line 7"));
941 assert!(out.contains("Todo"));
942 }
943
944 #[test]
945 fn find_task_info_via_format_value_as_text() {
946 let val = json!({
948 "done": true,
949 "line": 5,
950 "section": "Goals",
951 "status": "x",
952 "text": "Ship it"
953 });
954 let out = fmt(&val);
955 assert!(out.contains("[x]"));
956 assert!(out.contains("Ship it"));
957 assert!(
958 !out.contains("done: true"),
959 "should not use generic fallback"
960 );
961 }
962
963 #[test]
966 fn content_match_filter() {
967 let val = json!({
968 "line": 15,
969 "section": "Background",
970 "text": "This is the matching line"
971 });
972 let out = jq(CONTENT_MATCH_FILTER, &val).unwrap();
973 assert!(out.contains("line 15"));
974 assert!(out.contains("Background"));
975 assert!(out.contains("This is the matching line"));
976 }
977
978 #[test]
979 fn content_match_via_format_value_as_text() {
980 let val = json!({
981 "line": 3,
982 "section": "Intro",
983 "text": "hello world"
984 });
985 let out = fmt(&val);
986 assert!(out.contains("line 3"));
987 assert!(out.contains("hello world"));
988 assert!(!out.contains("line: 3"), "should not use generic fallback");
989 }
990
991 #[test]
994 fn property_value_mutation_filter_with_modified() {
995 let val = json!({
998 "modified": ["note-a.md", "note-b.md"],
999 "property": "status",
1000 "scanned": 2,
1001 "skipped": [],
1002 "total": 2,
1003 "value": "done"
1004 });
1005 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1006 assert!(out.contains("status=done"));
1007 assert!(out.contains("2/2 modified"));
1008 assert!(
1009 !out.contains("scanned"),
1010 "no scanned suffix when scanned == total"
1011 );
1012 assert!(out.contains("note-a.md"));
1013 assert!(out.contains("note-b.md"));
1014 }
1015
1016 #[test]
1017 fn property_value_mutation_filter_all_skipped() {
1018 let val = json!({
1019 "modified": [],
1020 "property": "priority",
1021 "scanned": 1,
1022 "skipped": ["note-a.md"],
1023 "total": 1,
1024 "value": "high"
1025 });
1026 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1027 assert!(out.contains("priority=high"));
1028 assert!(out.contains("0/1 modified"));
1029 assert!(!out.contains("note-a.md"));
1031 }
1032
1033 #[test]
1034 fn property_value_mutation_filter_with_where_filter() {
1035 let val = json!({
1037 "modified": ["note-a.md"],
1038 "property": "status",
1039 "scanned": 5,
1040 "skipped": [],
1041 "total": 1,
1042 "value": "done"
1043 });
1044 let out = jq(PROPERTY_VALUE_MUTATION_FILTER, &val).unwrap();
1045 assert!(out.contains("status=done"));
1046 assert!(out.contains("1/1 modified"));
1047 assert!(out.contains("(5 scanned)"));
1048 }
1049
1050 #[test]
1051 fn property_value_mutation_via_format_value_as_text() {
1052 let val = json!({
1053 "dry_run": false,
1054 "modified": ["notes/a.md"],
1055 "property": "status",
1056 "scanned": 1,
1057 "skipped": [],
1058 "total": 1,
1059 "value": "done"
1060 });
1061 let out = fmt(&val);
1062 assert!(out.contains("status=done"));
1063 assert!(
1064 !out.contains("modified: "),
1065 "should not use generic fallback"
1066 );
1067 }
1068
1069 #[test]
1070 fn property_mutation_filter_no_value() {
1071 let val = json!({
1073 "dry_run": false,
1074 "modified": ["note.md"],
1075 "property": "draft",
1076 "scanned": 1,
1077 "skipped": [],
1078 "total": 1
1079 });
1080 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1081 assert!(out.contains("draft"));
1082 assert!(out.contains("1/1 modified"));
1083 assert!(
1084 !out.contains("scanned"),
1085 "no scanned suffix when scanned == total"
1086 );
1087 assert!(out.contains("note.md"));
1088 }
1089
1090 #[test]
1091 fn property_mutation_filter_no_value_with_where_filter() {
1092 let val = json!({
1094 "dry_run": false,
1095 "modified": ["note.md"],
1096 "property": "draft",
1097 "scanned": 7,
1098 "skipped": [],
1099 "total": 1
1100 });
1101 let out = jq(PROPERTY_MUTATION_FILTER, &val).unwrap();
1102 assert!(out.contains("draft"));
1103 assert!(out.contains("1/1 modified"));
1104 assert!(out.contains("(7 scanned)"));
1105 }
1106
1107 #[test]
1108 fn tag_mutation_filter_with_modified() {
1109 let val = json!({
1111 "dry_run": false,
1112 "modified": ["a.md", "b.md"],
1113 "scanned": 3,
1114 "skipped": ["c.md"],
1115 "tag": "rust",
1116 "total": 3
1117 });
1118 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1119 assert!(out.contains("rust"));
1120 assert!(out.contains("2/3 modified"));
1121 assert!(
1122 !out.contains("scanned"),
1123 "no scanned suffix when scanned == total"
1124 );
1125 assert!(out.contains("a.md"));
1126 assert!(out.contains("b.md"));
1127 assert!(!out.contains("c.md"));
1128 }
1129
1130 #[test]
1131 fn tag_mutation_filter_with_where_filter() {
1132 let val = json!({
1134 "dry_run": false,
1135 "modified": ["a.md"],
1136 "scanned": 10,
1137 "skipped": [],
1138 "tag": "rust",
1139 "total": 1
1140 });
1141 let out = jq(TAG_MUTATION_FILTER, &val).unwrap();
1142 assert!(out.contains("rust"));
1143 assert!(out.contains("1/1 modified"));
1144 assert!(out.contains("(10 scanned)"));
1145 }
1146
1147 #[test]
1148 fn tag_mutation_via_format_value_as_text() {
1149 let val = json!({
1150 "dry_run": false,
1151 "modified": [],
1152 "scanned": 1,
1153 "skipped": ["note.md"],
1154 "tag": "cli",
1155 "total": 1
1156 });
1157 let out = fmt(&val);
1158 assert!(out.contains("cli"));
1159 assert!(!out.contains("tag: cli"), "should not use generic fallback");
1160 }
1161
1162 #[test]
1165 fn property_value_mutation_dry_run_prefix() {
1166 let val = json!({
1167 "dry_run": true,
1168 "modified": ["note.md"],
1169 "property": "status",
1170 "scanned": 1,
1171 "skipped": [],
1172 "total": 1,
1173 "value": "done"
1174 });
1175 let out = fmt(&val);
1176 assert!(
1177 out.contains("[dry-run] status=done"),
1178 "dry-run prefix missing: {out}"
1179 );
1180 }
1181
1182 #[test]
1183 fn tag_mutation_dry_run_prefix() {
1184 let val = json!({
1185 "dry_run": true,
1186 "modified": ["note.md"],
1187 "scanned": 1,
1188 "skipped": [],
1189 "tag": "rust",
1190 "total": 1
1191 });
1192 let out = fmt(&val);
1193 assert!(
1194 out.contains("[dry-run] rust"),
1195 "dry-run prefix missing: {out}"
1196 );
1197 }
1198
1199 #[test]
1200 fn property_value_mutation_no_dry_run_prefix() {
1201 let val = json!({
1202 "dry_run": false,
1203 "modified": ["note.md"],
1204 "property": "status",
1205 "scanned": 1,
1206 "skipped": [],
1207 "total": 1,
1208 "value": "done"
1209 });
1210 let out = fmt(&val);
1211 assert!(
1212 !out.contains("[dry-run]"),
1213 "should not have dry-run prefix: {out}"
1214 );
1215 }
1216
1217 #[test]
1220 fn build_file_object_filter_minimal() {
1221 let map: serde_json::Map<String, serde_json::Value> =
1223 serde_json::from_str(r#"{"file": "notes/foo.md", "modified": "2024-01-01"}"#).unwrap();
1224 let filter = build_file_object_filter(&map);
1225 let val = json!({"file": "notes/foo.md", "modified": "2024-01-01"});
1226 let out = jq(&filter, &val).unwrap();
1227 assert!(out.contains("notes/foo.md"));
1228 assert!(out.contains("2024-01-01"));
1229 }
1230
1231 #[test]
1232 fn build_file_object_filter_with_tags() {
1233 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1234 r#"{"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]}"#,
1235 )
1236 .unwrap();
1237 let filter = build_file_object_filter(&map);
1238 let val = json!({"file": "foo.md", "modified": "2024-01-01", "tags": ["rust", "cli"]});
1239 let out = jq(&filter, &val).unwrap();
1240 assert!(out.contains("foo.md"));
1241 assert!(out.contains("tags: [rust, cli]"));
1242 }
1243
1244 #[test]
1245 fn build_file_object_filter_with_properties() {
1246 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1247 r#"{"file": "foo.md", "modified": "2024-01-01", "properties": {"status": "done"}}"#,
1248 )
1249 .unwrap();
1250 let filter = build_file_object_filter(&map);
1251 let val = json!({
1252 "file": "foo.md",
1253 "modified": "2024-01-01",
1254 "properties": {"status": "done"}
1255 });
1256 let out = jq(&filter, &val).unwrap();
1257 assert!(out.contains("foo.md"));
1258 assert!(out.contains("properties:"));
1259 assert!(out.contains("status: done"));
1260 }
1261
1262 #[test]
1263 fn build_file_object_filter_with_tasks() {
1264 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1265 r#"{"file": "foo.md", "modified": "2024-01-01", "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]}"#,
1266 )
1267 .unwrap();
1268 let filter = build_file_object_filter(&map);
1269 let val = json!({
1270 "file": "foo.md",
1271 "modified": "2024-01-01",
1272 "tasks": [{"done": true, "line": 5, "section": "Goals", "status": "x", "text": "Ship it"}]
1273 });
1274 let out = jq(&filter, &val).unwrap();
1275 assert!(out.contains("foo.md"));
1276 assert!(out.contains("tasks:"));
1277 assert!(out.contains("[x] Ship it"));
1278 assert!(out.contains("line 5"));
1279 }
1280
1281 #[test]
1282 fn build_file_object_filter_with_sections() {
1283 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1284 r#"{"file": "foo.md", "modified": "2024-01-01", "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]}"#,
1285 )
1286 .unwrap();
1287 let filter = build_file_object_filter(&map);
1288 let val = json!({
1289 "file": "foo.md",
1290 "modified": "2024-01-01",
1291 "sections": [{"code_blocks": 0, "heading": "Intro", "level": 1, "line": 1, "links": []}]
1292 });
1293 let out = jq(&filter, &val).unwrap();
1294 assert!(out.contains("foo.md"));
1295 assert!(out.contains("sections:"));
1296 assert!(out.contains("# Intro"));
1297 }
1298
1299 #[test]
1300 fn build_file_object_filter_with_matches() {
1301 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1302 r#"{"file": "foo.md", "modified": "2024-01-01", "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]}"#,
1303 )
1304 .unwrap();
1305 let filter = build_file_object_filter(&map);
1306 let val = json!({
1307 "file": "foo.md",
1308 "modified": "2024-01-01",
1309 "matches": [{"line": 3, "section": "Intro", "text": "hello world"}]
1310 });
1311 let out = jq(&filter, &val).unwrap();
1312 assert!(out.contains("foo.md"));
1313 assert!(out.contains("matches:"));
1314 assert!(out.contains("line 3 (Intro): hello world"));
1315 }
1316
1317 #[test]
1318 fn build_file_object_filter_with_links() {
1319 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1320 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "bar", "path": "bar.md"}]}"#,
1321 )
1322 .unwrap();
1323 let filter = build_file_object_filter(&map);
1324 let val = json!({
1325 "file": "foo.md",
1326 "modified": "2024-01-01",
1327 "links": [{"target": "bar", "path": "bar.md"}]
1328 });
1329 let out = jq(&filter, &val).unwrap();
1330 assert!(out.contains("foo.md"));
1331 assert!(out.contains("links:"));
1332 assert!(out.contains(r#""bar" → "bar.md""#));
1333 }
1334
1335 #[test]
1336 fn build_file_object_filter_unresolved_link() {
1337 let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
1338 r#"{"file": "foo.md", "modified": "2024-01-01", "links": [{"target": "missing"}]}"#,
1339 )
1340 .unwrap();
1341 let filter = build_file_object_filter(&map);
1342 let val = json!({
1343 "file": "foo.md",
1344 "modified": "2024-01-01",
1345 "links": [{"target": "missing"}]
1346 });
1347 let out = jq(&filter, &val).unwrap();
1348 assert!(out.contains(r#""missing" (unresolved)"#));
1349 }
1350
1351 #[test]
1354 fn file_object_text_rendering_minimal() {
1355 let val = json!({"file": "notes/foo.md", "modified": "2024-01-15"});
1356 let out = fmt(&val);
1357 assert!(out.contains("notes/foo.md"));
1358 assert!(out.contains("2024-01-15"));
1359 assert!(!out.contains("file: notes/foo.md"));
1361 }
1362
1363 #[test]
1364 fn file_object_text_rendering_full() {
1365 let val = json!({
1366 "file": "notes/project.md",
1367 "modified": "2024-03-01",
1368 "tags": ["rust", "work"],
1369 "properties": {"status": "active"},
1370 "tasks": [
1371 {"done": false, "line": 10, "section": "Todo", "status": " ", "text": "Fix bug"},
1372 {"done": true, "line": 20, "section": "Done", "status": "x", "text": "Write docs"}
1373 ]
1374 });
1375 let out = fmt(&val);
1376 assert!(out.contains("notes/project.md"));
1377 assert!(out.contains("properties:"));
1378 assert!(out.contains("status: active"));
1379 assert!(out.contains("tags: [rust, work]"));
1380 assert!(out.contains("tasks:"));
1381 assert!(out.contains("[ ] Fix bug"));
1382 assert!(out.contains("[x] Write docs"));
1383 }
1384
1385 #[test]
1388 fn array_of_file_objects_uses_blank_line_separator() {
1389 let val = json!([
1390 {"file": "a.md", "modified": "2024-01-01"},
1391 {"file": "b.md", "modified": "2024-01-02"}
1392 ]);
1393 let out = fmt(&val);
1394 assert!(out.contains("a.md"));
1395 assert!(out.contains("b.md"));
1396 assert!(
1398 out.contains("\n\n"),
1399 "expected blank-line separator between file objects"
1400 );
1401 }
1402
1403 #[test]
1404 fn array_of_non_file_objects_uses_single_newline() {
1405 let val = json!([
1406 {"count": 1, "name": "status", "type": "text"},
1407 {"count": 3, "name": "title", "type": "text"}
1408 ]);
1409 let out = fmt(&val);
1410 assert!(out.contains("status"));
1411 assert!(out.contains("title"));
1412 assert!(
1414 !out.contains("\n\n"),
1415 "non-file-objects should use single newline"
1416 );
1417 }
1418
1419 #[test]
1422 fn format_scalar_delegates_nested_objects() {
1423 let inner = json!({"count": 2, "name": "status", "type": "text"});
1426 let out = scalar(&inner);
1427 assert!(
1429 !out.contains("count=2"),
1430 "should delegate to format_value_as_text"
1431 );
1432 assert!(out.contains("status"));
1434 assert!(out.contains("2 files"));
1435 }
1436
1437 #[test]
1440 fn format_value_as_text_uses_filter_for_known_shape() {
1441 let val = json!({"count": 3, "name": "status", "type": "text"});
1443 let out = fmt(&val);
1444 assert!(out.contains("status"));
1445 assert!(out.contains("3 files"));
1446 assert!(!out.contains("count: 3"));
1448 }
1449
1450 #[test]
1451 fn format_value_as_text_falls_back_for_unknown_shape() {
1452 let val = json!({"foo": "bar", "baz": 42});
1453 let out = fmt(&val);
1454 assert!(out.contains("foo: bar") || out.contains("baz: 42"));
1456 }
1457
1458 #[test]
1459 fn mv_result_filter_applied() {
1460 let val = json!({
1461 "dry_run": false,
1462 "from": "sub/b.md",
1463 "to": "archive/b.md",
1464 "total_files_updated": 1,
1465 "total_links_updated": 1,
1466 "updated_files": [
1467 {
1468 "file": "a.md",
1469 "replacements": [
1470 {"old_text": "[[sub/b]]", "new_text": "[[archive/b]]", "line": 1}
1471 ]
1472 }
1473 ]
1474 });
1475 let sig = {
1477 let map = val.as_object().unwrap();
1478 let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
1479 keys.sort_unstable();
1480 keys.join(",")
1481 };
1482 assert_eq!(
1483 sig,
1484 "dry_run,from,to,total_files_updated,total_links_updated,updated_files"
1485 );
1486 let filter_result = apply_jq_filter_result(MV_RESULT_FILTER, &val);
1488 assert!(filter_result.is_ok(), "filter error: {filter_result:?}");
1489 let out = filter_result.unwrap();
1490 assert!(out.contains("Moved sub/b.md"), "out: {out}");
1491 assert!(out.contains("archive/b.md"), "out: {out}");
1492 assert!(out.contains("[[sub/b]]"), "out: {out}");
1493 assert!(out.contains("[[archive/b]]"), "out: {out}");
1494 let found_filter =
1496 lookup_filter("dry_run,from,to,total_files_updated,total_links_updated,updated_files");
1497 assert!(
1498 found_filter.is_some(),
1499 "lookup_filter returned None for MvResult shape"
1500 );
1501 let formatted = fmt(&val);
1503 assert!(
1504 formatted.contains("Moved sub/b.md"),
1505 "formatted: {formatted}"
1506 );
1507 }
1508
1509 #[test]
1510 fn format_value_as_text_array_of_typed_objects() {
1511 let val = json!([
1512 {"path": "a.md", "tags": ["rust"]},
1513 {"path": "b.md", "tags": ["cli"]}
1514 ]);
1515 let out = fmt(&val);
1516 assert!(out.contains("a.md"));
1517 assert!(out.contains("b.md"));
1518 assert!(out.contains("rust"));
1519 assert!(out.contains("cli"));
1520 }
1521}