1const MAX_HINTS: usize = 5;
9
10#[derive(Debug, Clone)]
12pub struct Hint {
13 pub(crate) description: String,
14 pub(crate) cmd: String,
15}
16
17impl Hint {
18 fn new(description: impl Into<String>, cmd: String) -> Self {
19 Self {
20 description: description.into(),
21 cmd,
22 }
23 }
24}
25
26pub enum HintSource {
28 Summary,
29 PropertiesSummary,
30 TagsSummary,
31 Find,
32 Set,
33 Remove,
34 Append,
35 Read,
36 Backlinks,
37 Mv,
38 TaskRead,
39 TaskToggle,
40 TaskSetStatus,
41 LinksFix,
42 CreateIndex,
43 DropIndex,
44}
45
46pub struct HintContext {
52 pub source: HintSource,
53 pub dir: Option<String>,
55 pub glob: Vec<String>,
56 pub format: Option<String>,
58 pub hints: bool,
60 pub fields: Vec<String>,
62 pub sort: Option<String>,
63 pub has_limit: bool,
64 pub has_body_search: bool,
65 pub has_regex_search: bool,
66 pub property_filters: Vec<String>,
67 pub tag_filters: Vec<String>,
68 pub task_filter: Option<String>,
69 pub file_targets: Vec<String>,
70 pub dry_run: bool,
72 pub index_path: Option<String>,
74}
75
76impl HintContext {
77 pub fn new(source: HintSource) -> Self {
78 Self {
79 source,
80 dir: None,
81 glob: vec![],
82 format: None,
83 hints: false,
84 fields: vec![],
85 sort: None,
86 has_limit: false,
87 has_body_search: false,
88 has_regex_search: false,
89 property_filters: vec![],
90 tag_filters: vec![],
91 task_filter: None,
92 file_targets: vec![],
93 dry_run: false,
94 index_path: None,
95 }
96 }
97}
98
99#[must_use]
104pub fn generate_hints(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
105 let hints = match &ctx.source {
106 HintSource::Summary => hints_for_summary(ctx, data),
107 HintSource::PropertiesSummary => hints_for_properties_summary(ctx, data),
108 HintSource::TagsSummary => hints_for_tags_summary(ctx, data),
109 HintSource::Find => hints_for_find(ctx, data),
110 HintSource::Set | HintSource::Remove | HintSource::Append => hints_for_mutation(ctx, data),
111 HintSource::Read => hints_for_read(ctx, data),
112 HintSource::Backlinks => hints_for_backlinks(ctx, data),
113 HintSource::Mv => hints_for_mv(ctx, data),
114 HintSource::TaskRead => hints_for_task_read(ctx, data),
115 HintSource::TaskToggle | HintSource::TaskSetStatus => hints_for_task_mutation(ctx, data),
116 HintSource::LinksFix => hints_for_links_fix(ctx, data),
117 HintSource::CreateIndex => hints_for_create_index(ctx, data),
118 HintSource::DropIndex => hints_for_drop_index(ctx, data),
119 };
120 hints.into_iter().take(MAX_HINTS).collect()
121}
122
123fn push_global_flags(parts: &mut Vec<String>, ctx: &HintContext) {
129 if let Some(dir) = &ctx.dir {
130 parts.push("--dir".to_owned());
131 parts.push(shell_quote(dir));
132 }
133 if let Some(fmt) = &ctx.format {
134 parts.push("--format".to_owned());
135 parts.push(shell_quote(fmt));
136 }
137 if ctx.hints {
138 parts.push("--hints".to_owned());
139 }
140}
141
142fn build_command_no_glob(ctx: &HintContext, args: &[&str]) -> String {
144 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
145 for arg in args {
146 parts.push(shell_quote(arg));
147 }
148 push_global_flags(&mut parts, ctx);
149 parts.join(" ")
150}
151
152fn build_command_with_glob(ctx: &HintContext, args: &[&str]) -> String {
154 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
155 for arg in args {
156 parts.push(shell_quote(arg));
157 }
158 push_global_flags(&mut parts, ctx);
159 for glob in &ctx.glob {
160 parts.push("--glob".to_owned());
161 parts.push(shell_quote(glob));
162 }
163 parts.join(" ")
164}
165
166fn build_find_command_preserving_filters(ctx: &HintContext, extra_args: &[&str]) -> String {
170 let mut parts: Vec<String> = vec!["hyalo".to_owned(), "find".to_owned()];
171 for pf in &ctx.property_filters {
172 parts.push("--property".to_owned());
173 parts.push(shell_quote(pf));
174 }
175 for tf in &ctx.tag_filters {
176 parts.push("--tag".to_owned());
177 parts.push(shell_quote(tf));
178 }
179 if let Some(task) = &ctx.task_filter {
180 parts.push("--task".to_owned());
181 parts.push(shell_quote(task));
182 }
183 for ft in &ctx.file_targets {
184 parts.push("--file".to_owned());
185 parts.push(shell_quote(ft));
186 }
187 for arg in extra_args {
188 parts.push(shell_quote(arg));
189 }
190 push_global_flags(&mut parts, ctx);
191 for glob in &ctx.glob {
192 parts.push("--glob".to_owned());
193 parts.push(shell_quote(glob));
194 }
195 parts.join(" ")
196}
197
198pub fn shell_quote(s: &str) -> String {
203 if s.is_empty()
204 || s.chars().any(|c| {
205 !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '/' | ':' | '@' | '=' | ',' | '+')
206 })
207 {
208 format!("'{}'", s.replace('\'', "'\\''"))
211 } else {
212 s.to_owned()
213 }
214}
215
216fn status_priority(value: &str) -> u8 {
222 if value.eq_ignore_ascii_case("in-progress")
223 || value.eq_ignore_ascii_case("in progress")
224 || value.eq_ignore_ascii_case("active")
225 {
226 0
227 } else if value.eq_ignore_ascii_case("planned") || value.eq_ignore_ascii_case("todo") {
228 1
229 } else if value.eq_ignore_ascii_case("draft") || value.eq_ignore_ascii_case("idea") {
230 2
231 } else if value.eq_ignore_ascii_case("completed")
232 || value.eq_ignore_ascii_case("done")
233 || value.eq_ignore_ascii_case("archived")
234 {
235 4
236 } else {
237 3
238 }
239}
240
241fn first_modified_file(data: &serde_json::Value) -> Option<&str> {
247 fn extract(obj: &serde_json::Value) -> Option<&str> {
248 obj.get("modified")
249 .and_then(|m| m.as_array())
250 .and_then(|a| a.first())
251 .and_then(|f| f.as_str())
252 }
253 if let Some(arr) = data.as_array() {
254 arr.iter().find_map(extract)
255 } else {
256 extract(data)
257 }
258}
259
260fn hints_for_summary(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
265 let mut hints = Vec::new();
266
267 hints.push(Hint::new(
268 "Browse property names and types",
269 build_command_with_glob(ctx, &["properties"]),
270 ));
271 hints.push(Hint::new(
272 "Browse tags and their counts",
273 build_command_with_glob(ctx, &["tags"]),
274 ));
275
276 let tasks_total = data
278 .get("tasks")
279 .and_then(|t| t.get("total"))
280 .and_then(serde_json::Value::as_u64)
281 .unwrap_or(0);
282 let tasks_done = data
283 .get("tasks")
284 .and_then(|t| t.get("done"))
285 .and_then(serde_json::Value::as_u64)
286 .unwrap_or(0);
287 if tasks_total > tasks_done {
288 hints.push(Hint::new(
289 "Find files with open tasks",
290 build_command_with_glob(ctx, &["find", "--task", "todo"]),
291 ));
292 }
293
294 let broken_links = data
296 .get("links")
297 .and_then(|l| l.get("broken"))
298 .and_then(serde_json::Value::as_u64)
299 .unwrap_or(0);
300 if broken_links > 0 {
301 let remaining = MAX_HINTS.saturating_sub(hints.len());
302 if remaining > 0 {
303 hints.push(Hint::new(
304 "List files with broken links",
305 build_command_with_glob(ctx, &["find", "--broken-links"]),
306 ));
307 }
308 let remaining = MAX_HINTS.saturating_sub(hints.len());
309 if remaining > 0 {
310 hints.push(Hint::new(
311 "Auto-fix broken links (dry run)",
312 build_command_with_glob(ctx, &["links", "fix"]),
313 ));
314 }
315 }
316
317 if let Some(status_arr) = data.get("status").and_then(|s| s.as_array()) {
319 let mut groups: Vec<(&str, u8)> = status_arr
320 .iter()
321 .filter_map(|g| {
322 let value = g.get("value").and_then(|v| v.as_str())?;
323 Some((value, status_priority(value)))
324 })
325 .collect();
326 groups.sort_by_key(|&(_, p)| p);
327
328 let remaining = MAX_HINTS.saturating_sub(hints.len());
329 for (value, _) in groups.into_iter().take(remaining.min(2)) {
330 let filter = format!("status={value}");
331 hints.push(Hint::new(
332 format!("Filter by status: {value}"),
333 build_command_no_glob(ctx, &["find", "--property", &filter]),
334 ));
335 }
336 }
337
338 hints
339}
340
341fn hints_for_properties_summary(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
342 let Some(arr) = data.as_array() else {
343 return vec![];
344 };
345
346 let mut entries: Vec<(&str, u64)> = arr
348 .iter()
349 .filter_map(|e| {
350 let name = e.get("name").and_then(|n| n.as_str())?;
351 let count = e
352 .get("count")
353 .and_then(serde_json::Value::as_u64)
354 .unwrap_or(0);
355 Some((name, count))
356 })
357 .collect();
358 entries.sort_by(|a, b| b.1.cmp(&a.1));
359
360 entries
361 .into_iter()
362 .take(3)
363 .map(|(name, count)| {
364 Hint::new(
365 format!("Find {count} files with property: {name}"),
366 build_command_with_glob(ctx, &["find", "--property", name]),
367 )
368 })
369 .collect()
370}
371
372fn hints_for_find(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
373 let Some(results) = data.as_array() else {
375 return vec![];
376 };
377
378 if results.is_empty() {
379 return vec![];
380 }
381
382 let mut hints = Vec::new();
383 let result_count = results.len();
384 let is_single = result_count == 1;
385
386 if let Some(first_file) = results[0].get("file").and_then(|f| f.as_str()) {
388 hints.push(Hint::new(
389 "Read this file's content",
390 build_command_no_glob(ctx, &["read", "--file", first_file]),
391 ));
392 if is_single {
393 hints.push(Hint::new(
394 "See all metadata for this file",
395 build_command_no_glob(ctx, &["find", "--file", first_file, "--fields", "all"]),
396 ));
397 }
398 hints.push(Hint::new(
399 "See what links to this file",
400 build_command_no_glob(ctx, &["backlinks", "--file", first_file]),
401 ));
402 }
403
404 let has_no_filters = ctx.property_filters.is_empty()
406 && ctx.tag_filters.is_empty()
407 && ctx.task_filter.is_none()
408 && !ctx.has_body_search
409 && !ctx.has_regex_search
410 && ctx.file_targets.is_empty();
411
412 if has_no_filters && result_count > 10 {
413 hints.push(Hint::new(
414 if ctx.glob.is_empty() {
415 "Get a high-level vault overview"
416 } else {
417 "Get stats for this file set"
418 },
419 build_command_with_glob(ctx, &["summary"]),
420 ));
421 }
422
423 if result_count > 5 {
425 let mut tag_counts: std::collections::HashMap<&str, usize> =
427 std::collections::HashMap::new();
428 for item in results {
429 if let Some(tags) = item.get("tags").and_then(|t| t.as_array()) {
430 for tag in tags {
431 if let Some(name) = tag.as_str()
432 && !ctx.tag_filters.iter().any(|t| t == name)
433 {
434 *tag_counts.entry(name).or_insert(0) += 1;
435 }
436 }
437 }
438 }
439
440 let mut status_counts: std::collections::HashMap<&str, usize> =
443 std::collections::HashMap::new();
444 for item in results {
445 let Some(status_val) = item.get("properties").and_then(|p| p.get("status")) else {
446 continue;
447 };
448 let iter: Box<dyn Iterator<Item = &str>> = match status_val {
450 serde_json::Value::String(s) => Box::new(std::iter::once(s.as_str())),
451 serde_json::Value::Array(arr) => Box::new(arr.iter().filter_map(|v| v.as_str())),
452 _ => Box::new(std::iter::empty()),
453 };
454 for status in iter {
455 let already_filtered = ctx
456 .property_filters
457 .iter()
458 .any(|f| f == &format!("status={status}"));
459 if !already_filtered {
460 *status_counts.entry(status).or_insert(0) += 1;
461 }
462 }
463 }
464
465 if let Some((top_tag, count)) = tag_counts
468 .iter()
469 .max_by(|(a_tag, a_cnt), (b_tag, b_cnt)| a_cnt.cmp(b_cnt).then(b_tag.cmp(a_tag)))
470 {
471 let remaining = MAX_HINTS.saturating_sub(hints.len());
472 if remaining > 0 {
473 hints.push(Hint::new(
474 format!("Narrow by tag: {top_tag} ({count} files)"),
475 build_command_with_glob(ctx, &["find", "--tag", top_tag]),
476 ));
477 }
478 }
479
480 let mut status_vec: Vec<(&str, usize, u8)> = status_counts
482 .iter()
483 .map(|(v, c)| (*v, *c, status_priority(v)))
484 .collect();
485 status_vec.sort_by(|a, b| a.2.cmp(&b.2).then(b.1.cmp(&a.1)).then(a.0.cmp(b.0)));
487
488 if let Some((top_status, count, _)) = status_vec.first() {
489 let remaining = MAX_HINTS.saturating_sub(hints.len());
490 if remaining > 0 {
491 hints.push(Hint::new(
492 format!("Filter by status: {top_status} ({count} files)"),
493 build_command_with_glob(
494 ctx,
495 &["find", "--property", &format!("status={top_status}")],
496 ),
497 ));
498 }
499 }
500
501 if ctx.sort.is_none() {
503 let remaining = MAX_HINTS.saturating_sub(hints.len());
504 if remaining > 0 {
505 hints.push(Hint::new(
506 "Sort by most recently modified",
507 build_find_command_preserving_filters(
508 ctx,
509 &["--sort", "modified", "--reverse"],
510 ),
511 ));
512 }
513 }
514
515 if !ctx.has_limit {
517 let remaining = MAX_HINTS.saturating_sub(hints.len());
518 if remaining > 0 {
519 hints.push(Hint::new(
520 "Limit to 10 results",
521 build_find_command_preserving_filters(ctx, &["--limit", "10"]),
522 ));
523 }
524 }
525 }
526
527 let has_broken_links = results.iter().any(|item| {
534 item.get("links")
535 .and_then(|l| l.as_array())
536 .is_some_and(|links| {
537 links
538 .iter()
539 .any(|link| link.get("path").is_some_and(serde_json::Value::is_null))
540 })
541 });
542 if has_broken_links {
543 let remaining = MAX_HINTS.saturating_sub(hints.len());
544 if remaining > 0 {
545 hints.push(Hint::new(
546 "Auto-fix broken links (dry run)",
547 build_command_with_glob(ctx, &["links", "fix"]),
548 ));
549 }
550 }
551
552 hints
553}
554
555fn hints_for_tags_summary(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
556 let Some(tags_arr) = data.as_array() else {
558 return vec![];
559 };
560
561 let mut entries: Vec<(&str, u64)> = tags_arr
563 .iter()
564 .filter_map(|entry| {
565 let name = entry.get("name").and_then(|n| n.as_str())?;
566 let count = entry
567 .get("count")
568 .and_then(serde_json::Value::as_u64)
569 .unwrap_or(0);
570 Some((name, count))
571 })
572 .collect();
573 entries.sort_by(|a, b| b.1.cmp(&a.1));
574
575 entries
576 .into_iter()
577 .take(3)
578 .map(|(name, count)| {
579 Hint::new(
580 format!("Find {count} files tagged: {name}"),
581 build_command_with_glob(ctx, &["find", "--tag", name]),
582 )
583 })
584 .collect()
585}
586
587fn hints_for_mutation(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
588 let mut hints = Vec::new();
589
590 let first_modified = first_modified_file(data);
591
592 if let Some(file) = first_modified {
593 hints.push(Hint::new(
594 "Verify the updated file",
595 build_command_no_glob(
596 ctx,
597 &["find", "--file", file, "--fields", "properties,tags"],
598 ),
599 ));
600 hints.push(Hint::new(
601 "Read the modified file",
602 build_command_no_glob(ctx, &["read", "--file", file]),
603 ));
604 }
605
606 hints
607}
608
609fn hints_for_read(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
610 let mut hints = Vec::new();
611
612 let file = data
613 .get("file")
614 .and_then(|f| f.as_str())
615 .or_else(|| ctx.file_targets.first().map(String::as_str));
616
617 if let Some(file) = file {
618 hints.push(Hint::new(
619 "See metadata for this file",
620 build_command_no_glob(ctx, &["find", "--file", file, "--fields", "all"]),
621 ));
622 hints.push(Hint::new(
623 "See what links to this file",
624 build_command_no_glob(ctx, &["backlinks", "--file", file]),
625 ));
626 }
627
628 hints
629}
630
631fn hints_for_backlinks(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
632 let mut hints = Vec::new();
633
634 let file = data.get("file").and_then(|f| f.as_str());
635
636 if let Some(file) = file {
637 hints.push(Hint::new(
638 "Read this file's content",
639 build_command_no_glob(ctx, &["read", "--file", file]),
640 ));
641 hints.push(Hint::new(
642 "See this file's outgoing links",
643 build_command_no_glob(ctx, &["find", "--file", file, "--fields", "links"]),
644 ));
645 }
646
647 if let Some(backlinks) = data.get("backlinks").and_then(|b| b.as_array())
649 && let Some(first_source) = backlinks
650 .first()
651 .and_then(|b| b.get("source"))
652 .and_then(|s| s.as_str())
653 {
654 hints.push(Hint::new(
655 format!("Read linking file: {first_source}"),
656 build_command_no_glob(ctx, &["read", "--file", first_source]),
657 ));
658 }
659
660 hints
661}
662
663fn hints_for_mv(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
664 let mut hints = Vec::new();
665
666 let to_path = data.get("to").and_then(|t| t.as_str());
667 let is_dry_run = data
668 .get("dry_run")
669 .and_then(serde_json::Value::as_bool)
670 .unwrap_or(false);
671
672 if let Some(to_path) = to_path {
673 if is_dry_run {
674 if let Some(from_path) = data.get("from").and_then(|f| f.as_str()) {
675 hints.push(Hint::new(
676 "Apply this move",
677 build_command_no_glob(ctx, &["mv", "--file", from_path, "--to", to_path]),
678 ));
679 }
680 } else {
681 hints.push(Hint::new(
682 "Read the moved file",
683 build_command_no_glob(ctx, &["read", "--file", to_path]),
684 ));
685 hints.push(Hint::new(
686 "Verify backlinks updated",
687 build_command_no_glob(ctx, &["backlinks", "--file", to_path]),
688 ));
689 }
690 }
691
692 hints
693}
694
695fn hints_for_task_read(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
697 let mut hints = Vec::new();
698
699 let file = data.get("file").and_then(|f| f.as_str());
700 let line = data.get("line").and_then(serde_json::Value::as_u64);
701 let done = data
702 .get("done")
703 .and_then(serde_json::Value::as_bool)
704 .unwrap_or(false);
705
706 if let (Some(file), Some(line)) = (file, line) {
707 let line_str = line.to_string();
708 if !done {
709 hints.push(Hint::new(
710 "Toggle this task to done",
711 build_command_no_glob(
712 ctx,
713 &["task", "toggle", "--file", file, "--line", &line_str],
714 ),
715 ));
716 }
717 hints.push(Hint::new(
718 "See all open tasks in this file",
719 build_command_no_glob(
720 ctx,
721 &[
722 "find", "--file", file, "--task", "todo", "--fields", "tasks",
723 ],
724 ),
725 ));
726 }
727
728 hints
729}
730
731fn hints_for_task_mutation(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
732 let mut hints = Vec::new();
733
734 let file = data.get("file").and_then(|f| f.as_str());
735
736 if let Some(file) = file {
737 hints.push(Hint::new(
738 "See remaining open tasks",
739 build_command_no_glob(
740 ctx,
741 &[
742 "find", "--file", file, "--task", "todo", "--fields", "tasks",
743 ],
744 ),
745 ));
746 hints.push(Hint::new(
747 "Read the file",
748 build_command_no_glob(ctx, &["read", "--file", file]),
749 ));
750 }
751
752 hints
753}
754
755fn hints_for_links_fix(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
756 let mut hints = Vec::new();
757
758 let is_dry_run = !data
759 .get("applied")
760 .and_then(serde_json::Value::as_bool)
761 .unwrap_or(false);
762 let fixable = data
763 .get("fixable")
764 .and_then(serde_json::Value::as_u64)
765 .unwrap_or(0);
766 let unfixable = data
767 .get("unfixable")
768 .and_then(serde_json::Value::as_u64)
769 .unwrap_or(0);
770
771 if is_dry_run && fixable > 0 {
772 hints.push(Hint::new(
773 format!("Apply {fixable} fixes"),
774 build_command_with_glob(ctx, &["links", "fix", "--apply"]),
775 ));
776 }
777
778 if unfixable > 0 {
779 hints.push(Hint::new(
780 "List files with remaining broken links",
781 build_command_with_glob(ctx, &["find", "--broken-links"]),
782 ));
783 }
784
785 hints
786}
787
788fn hints_for_create_index(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
789 let mut hints = Vec::new();
790
791 let index_path = data
792 .get("path")
793 .and_then(|p| p.as_str())
794 .or(ctx.index_path.as_deref())
795 .unwrap_or(".hyalo-index");
796
797 hints.push(Hint::new(
798 "Query using the index",
799 build_command_no_glob(ctx, &["find", "--index", index_path]),
800 ));
801 hints.push(Hint::new(
802 "Delete the index when done",
803 build_command_no_glob(ctx, &["drop-index"]),
804 ));
805
806 hints
807}
808
809fn hints_for_drop_index(ctx: &HintContext, _data: &serde_json::Value) -> Vec<Hint> {
810 vec![Hint::new(
811 "Rebuild the index",
812 build_command_no_glob(ctx, &["create-index"]),
813 )]
814}
815
816#[cfg(test)]
817mod tests {
818 use super::*;
819 use serde_json::json;
820
821 fn ctx(source: HintSource) -> HintContext {
822 HintContext::new(source)
823 }
824
825 fn ctx_with_dir(source: HintSource, dir: &str) -> HintContext {
826 let mut ctx = HintContext::new(source);
827 ctx.dir = Some(dir.to_owned());
828 ctx
829 }
830
831 fn ctx_with_glob(source: HintSource, glob: &str) -> HintContext {
832 let mut ctx = HintContext::new(source);
833 ctx.glob = vec![glob.to_owned()];
834 ctx
835 }
836
837 #[test]
840 fn shell_quote_plain_string() {
841 assert_eq!(shell_quote("status"), "status");
842 }
843
844 #[test]
845 fn shell_quote_string_with_space() {
846 assert_eq!(shell_quote("in progress"), "'in progress'");
847 }
848
849 #[test]
850 fn shell_quote_string_with_special_chars() {
851 assert_eq!(shell_quote("foo$bar"), "'foo$bar'");
852 }
853
854 #[test]
855 fn shell_quote_string_with_single_quote() {
856 assert_eq!(shell_quote("it's"), "'it'\\''s'");
857 }
858
859 #[test]
860 fn shell_quote_glob_chars() {
861 assert_eq!(shell_quote("**/*.md"), "'**/*.md'");
862 }
863
864 #[test]
865 fn shell_quote_empty_string() {
866 assert_eq!(shell_quote(""), "''");
867 }
868
869 #[test]
872 fn build_command_no_flags() {
873 let c = ctx(HintSource::Summary);
874 assert_eq!(
875 build_command_no_glob(&c, &["properties"]),
876 "hyalo properties"
877 );
878 }
879
880 #[test]
881 fn build_command_with_dir() {
882 let c = ctx_with_dir(HintSource::Summary, "/my/vault");
883 assert_eq!(
884 build_command_no_glob(&c, &["tags"]),
885 "hyalo tags --dir /my/vault"
886 );
887 }
888
889 #[test]
890 fn build_command_with_glob_propagated() {
891 let c = ctx_with_glob(HintSource::PropertiesSummary, "**/*.md");
892 assert_eq!(
893 build_command_with_glob(&c, &["properties"]),
894 "hyalo properties --glob '**/*.md'"
895 );
896 }
897
898 #[test]
901 fn status_priority_ordering() {
902 assert!(status_priority("in-progress") < status_priority("planned"));
903 assert!(status_priority("planned") < status_priority("draft"));
904 assert!(status_priority("draft") < status_priority("custom"));
905 assert!(status_priority("custom") < status_priority("completed"));
906 }
907
908 #[test]
911 fn summary_always_includes_properties_and_tags() {
912 let c = ctx(HintSource::Summary);
913 let data = json!({
914 "files": {"total": 10, "by_directory": []},
915 "properties": [],
916 "tags": {"tags": [], "total": 0},
917 "status": [],
918 "tasks": {"total": 0, "done": 0},
919 "recent_files": []
920 });
921 let hints = generate_hints(&c, &data);
922 assert!(hints.iter().any(|h| {
923 h.cmd == "hyalo properties"
924 || (h.cmd.starts_with("hyalo properties ") && h.cmd.contains("--dir "))
925 || (h.cmd.starts_with("hyalo properties ") && h.cmd.contains("--glob "))
926 }));
927 assert!(hints.iter().any(|h| {
928 h.cmd == "hyalo tags"
929 || (h.cmd.starts_with("hyalo tags ") && h.cmd.contains("--dir "))
930 || (h.cmd.starts_with("hyalo tags ") && h.cmd.contains("--glob "))
931 }));
932 }
933
934 #[test]
935 fn summary_suggests_tasks_todo_when_open_tasks() {
936 let c = ctx(HintSource::Summary);
937 let data = json!({
938 "files": {"total": 5, "by_directory": []},
939 "properties": [],
940 "tags": {"tags": [], "total": 0},
941 "status": [],
942 "tasks": {"total": 10, "done": 3},
943 "recent_files": []
944 });
945 let hints = generate_hints(&c, &data);
946 assert!(
947 hints.iter().any(|h| h.cmd.contains("find")
948 && h.cmd.contains("--task")
949 && h.cmd.contains("todo"))
950 );
951 }
952
953 #[test]
954 fn summary_omits_tasks_todo_when_all_done() {
955 let c = ctx(HintSource::Summary);
956 let data = json!({
957 "files": {"total": 5, "by_directory": []},
958 "properties": [],
959 "tags": {"tags": [], "total": 0},
960 "status": [],
961 "tasks": {"total": 10, "done": 10},
962 "recent_files": []
963 });
964 let hints = generate_hints(&c, &data);
965 assert!(!hints.iter().any(|h| h.cmd.contains("--todo")));
966 }
967
968 #[test]
969 fn summary_picks_interesting_status_values() {
970 let c = ctx(HintSource::Summary);
971 let data = json!({
972 "files": {"total": 5, "by_directory": []},
973 "properties": [],
974 "tags": {"tags": [], "total": 0},
975 "status": [
976 {"value": "completed", "files": ["a.md"]},
977 {"value": "in-progress", "files": ["b.md"]},
978 {"value": "planned", "files": ["c.md"]}
979 ],
980 "tasks": {"total": 0, "done": 0},
981 "recent_files": []
982 });
983 let hints = generate_hints(&c, &data);
984 let in_progress_pos = hints.iter().position(|h| h.cmd.contains("in-progress"));
986 let completed_pos = hints.iter().position(|h| h.cmd.contains("completed"));
987 assert!(in_progress_pos.is_some(), "should suggest in-progress");
988 if let Some(cp) = completed_pos {
990 assert!(in_progress_pos.unwrap() < cp);
991 }
992 }
993
994 #[test]
995 fn summary_max_hints_not_exceeded() {
996 let c = ctx(HintSource::Summary);
997 let data = json!({
998 "files": {"total": 5, "by_directory": []},
999 "properties": [],
1000 "tags": {"tags": [], "total": 0},
1001 "status": [
1002 {"value": "in-progress", "files": ["a.md"]},
1003 {"value": "planned", "files": ["b.md"]},
1004 {"value": "draft", "files": ["c.md"]},
1005 {"value": "idea", "files": ["d.md"]}
1006 ],
1007 "tasks": {"total": 5, "done": 1},
1008 "recent_files": []
1009 });
1010 let hints = generate_hints(&c, &data);
1011 assert!(hints.len() <= MAX_HINTS);
1012 }
1013
1014 #[test]
1017 fn properties_summary_top3_by_count() {
1018 let c = ctx(HintSource::PropertiesSummary);
1019 let data = json!([
1020 {"name": "title", "type": "text", "count": 100},
1021 {"name": "status", "type": "text", "count": 50},
1022 {"name": "tags", "type": "list", "count": 30},
1023 {"name": "author", "type": "text", "count": 5}
1024 ]);
1025 let hints = generate_hints(&c, &data);
1026 assert_eq!(hints.len(), 3);
1027 assert!(hints[0].cmd.contains("title"));
1028 assert!(hints[1].cmd.contains("status"));
1029 assert!(hints[2].cmd.contains("tags"));
1030 assert!(!hints.iter().any(|h| h.cmd.contains("author")));
1032 }
1033
1034 #[test]
1035 fn properties_summary_empty_data() {
1036 let c = ctx(HintSource::PropertiesSummary);
1037 let hints = generate_hints(&c, &json!([]));
1038 assert!(hints.is_empty());
1039 }
1040
1041 #[test]
1042 fn properties_summary_propagates_glob() {
1043 let c = ctx_with_glob(HintSource::PropertiesSummary, "notes/*.md");
1044 let data = json!([{"name": "status", "type": "text", "count": 5}]);
1045 let hints = generate_hints(&c, &data);
1046 assert!(hints[0].cmd.contains("--glob"));
1047 assert!(hints[0].cmd.contains("notes/*.md"));
1048 }
1049
1050 fn make_find_item(file: &str, status: Option<&str>, tags: &[&str]) -> serde_json::Value {
1053 let mut props = serde_json::Map::new();
1054 if let Some(s) = status {
1055 props.insert("status".to_owned(), serde_json::Value::String(s.to_owned()));
1056 }
1057 json!({
1058 "file": file,
1059 "properties": props,
1060 "tags": tags,
1061 "sections": [],
1062 "tasks": [],
1063 "links": [],
1064 "modified": "2026-01-01T00:00:00Z"
1065 })
1066 }
1067
1068 #[test]
1069 fn find_empty_results_no_hints() {
1070 let c = ctx(HintSource::Find);
1071 let hints = generate_hints(&c, &json!([]));
1072 assert!(hints.is_empty());
1073 }
1074
1075 #[test]
1076 fn find_single_result_suggests_read_and_backlinks() {
1077 let c = ctx(HintSource::Find);
1078 let items = vec![make_find_item("notes/alpha.md", None, &[])];
1079 let data = json!(items);
1080 let hints = generate_hints(&c, &data);
1081 assert!(
1082 hints
1083 .iter()
1084 .any(|h| h.cmd.contains("read") && h.cmd.contains("alpha.md")),
1085 "should suggest read: {hints:?}"
1086 );
1087 assert!(
1088 hints
1089 .iter()
1090 .any(|h| h.cmd.contains("backlinks") && h.cmd.contains("alpha.md")),
1091 "should suggest backlinks: {hints:?}"
1092 );
1093 }
1094
1095 #[test]
1096 fn find_many_results_suggests_top_tag() {
1097 let c = ctx(HintSource::Find);
1098 let items = vec![
1100 make_find_item("a.md", Some("planned"), &["rust", "cli"]),
1101 make_find_item("b.md", Some("planned"), &["rust"]),
1102 make_find_item("c.md", Some("in-progress"), &["rust"]),
1103 make_find_item("d.md", Some("completed"), &["rust"]),
1104 make_find_item("e.md", Some("completed"), &["cli"]),
1105 make_find_item("f.md", Some("completed"), &[]),
1106 ];
1107 let data = json!(items);
1108 let hints = generate_hints(&c, &data);
1109 assert!(
1110 hints
1111 .iter()
1112 .any(|h| h.cmd.contains("--tag") && h.cmd.contains("rust")),
1113 "should suggest --tag rust (most common): {hints:?}"
1114 );
1115 }
1116
1117 #[test]
1118 fn find_many_results_suggests_interesting_status() {
1119 let c = ctx(HintSource::Find);
1120 let items = vec![
1122 make_find_item("a.md", Some("in-progress"), &[]),
1123 make_find_item("b.md", Some("completed"), &[]),
1124 make_find_item("c.md", Some("completed"), &[]),
1125 make_find_item("d.md", Some("completed"), &[]),
1126 make_find_item("e.md", Some("completed"), &[]),
1127 make_find_item("f.md", Some("completed"), &[]),
1128 ];
1129 let data = json!(items);
1130 let hints = generate_hints(&c, &data);
1131 assert!(
1132 hints
1133 .iter()
1134 .any(|h| h.cmd.contains("--property") && h.cmd.contains("status=in-progress")),
1135 "should prefer in-progress status: {hints:?}"
1136 );
1137 }
1138
1139 #[test]
1140 fn find_many_results_no_tags_falls_back_to_status() {
1141 let c = ctx(HintSource::Find);
1142 let items = vec![
1144 make_find_item("a.md", Some("planned"), &[]),
1145 make_find_item("b.md", Some("planned"), &[]),
1146 make_find_item("c.md", Some("planned"), &[]),
1147 make_find_item("d.md", Some("planned"), &[]),
1148 make_find_item("e.md", Some("planned"), &[]),
1149 make_find_item("f.md", Some("planned"), &[]),
1150 ];
1151 let data = json!(items);
1152 let hints = generate_hints(&c, &data);
1153 assert!(
1154 hints
1155 .iter()
1156 .any(|h| h.cmd.contains("--property") && h.cmd.contains("status=planned")),
1157 "should suggest status filter: {hints:?}"
1158 );
1159 assert!(
1161 !hints.iter().any(|h| h.cmd.contains("--tag")),
1162 "should not suggest --tag when no tags: {hints:?}"
1163 );
1164 }
1165
1166 #[test]
1167 fn find_hints_never_exceed_max() {
1168 let c = ctx(HintSource::Find);
1169 let items: Vec<serde_json::Value> = (0..10)
1171 .map(|i| make_find_item(&format!("{i}.md"), Some("planned"), &["rust", "cli"]))
1172 .collect();
1173 let data = json!(items);
1174 let hints = generate_hints(&c, &data);
1175 assert!(hints.len() <= MAX_HINTS);
1176 }
1177
1178 #[test]
1179 fn find_sort_hint_preserves_existing_filters() {
1180 let mut c = ctx(HintSource::Find);
1181 c.property_filters = vec!["status=draft".to_owned()];
1182 c.tag_filters = vec!["research".to_owned()];
1183 let items: Vec<serde_json::Value> = (0..6)
1185 .map(|i| make_find_item(&format!("{i}.md"), Some("draft"), &["research"]))
1186 .collect();
1187 let data = json!(items);
1188 let hints = generate_hints(&c, &data);
1189 let sort_hint = hints.iter().find(|h| h.cmd.contains("--sort"));
1190 assert!(sort_hint.is_some(), "should include a sort hint: {hints:?}");
1191 let cmd = &sort_hint.unwrap().cmd;
1192 assert!(
1193 cmd.contains("--property status=draft"),
1194 "sort hint should preserve --property filter: {cmd}"
1195 );
1196 assert!(
1197 cmd.contains("--tag research"),
1198 "sort hint should preserve --tag filter: {cmd}"
1199 );
1200 }
1201
1202 #[test]
1203 fn find_limit_hint_preserves_existing_filters() {
1204 let mut c = ctx(HintSource::Find);
1205 c.tag_filters = vec!["iteration".to_owned()];
1206 let items: Vec<serde_json::Value> = (0..6)
1207 .map(|i| make_find_item(&format!("{i}.md"), Some("planned"), &["iteration"]))
1208 .collect();
1209 let data = json!(items);
1210 let hints = generate_hints(&c, &data);
1211 let limit_hint = hints.iter().find(|h| h.cmd.contains("--limit"));
1212 assert!(
1213 limit_hint.is_some(),
1214 "should include a limit hint: {hints:?}"
1215 );
1216 let cmd = &limit_hint.unwrap().cmd;
1217 assert!(
1218 cmd.contains("--tag iteration"),
1219 "limit hint should preserve --tag filter: {cmd}"
1220 );
1221 }
1222
1223 #[test]
1226 fn dir_flag_propagated_to_all_hints() {
1227 let c = ctx_with_dir(HintSource::TagsSummary, "/vault");
1228 let data = json!([{"name": "rust", "count": 5}]);
1230 let hints = generate_hints(&c, &data);
1231 assert!(hints[0].cmd.contains("--dir"));
1232 assert!(hints[0].cmd.contains("/vault"));
1233 }
1234
1235 #[test]
1238 fn mutation_hints_suggest_verify_and_read() {
1239 let c = ctx(HintSource::Set);
1240 let data = json!({
1241 "property": "status",
1242 "value": "completed",
1243 "modified": ["notes/alpha.md"],
1244 "skipped": [],
1245 "total": 1
1246 });
1247 let hints = generate_hints(&c, &data);
1248 assert!(
1249 hints
1250 .iter()
1251 .any(|h| h.cmd.contains("find") && h.cmd.contains("alpha.md")),
1252 "should suggest verify: {hints:?}"
1253 );
1254 assert!(
1255 hints
1256 .iter()
1257 .any(|h| h.cmd.contains("read") && h.cmd.contains("alpha.md")),
1258 "should suggest read: {hints:?}"
1259 );
1260 }
1261
1262 #[test]
1263 fn read_hints_suggest_metadata_and_backlinks() {
1264 let c = ctx(HintSource::Read);
1265 let data = json!({"file": "notes/alpha.md", "content": "Some content"});
1266 let hints = generate_hints(&c, &data);
1267 assert!(
1268 hints
1269 .iter()
1270 .any(|h| h.cmd.contains("find") && h.cmd.contains("alpha.md")),
1271 "should suggest find: {hints:?}"
1272 );
1273 assert!(
1274 hints
1275 .iter()
1276 .any(|h| h.cmd.contains("backlinks") && h.cmd.contains("alpha.md")),
1277 "should suggest backlinks: {hints:?}"
1278 );
1279 }
1280
1281 #[test]
1282 fn backlinks_hints_suggest_read_and_outgoing() {
1283 let c = ctx(HintSource::Backlinks);
1284 let data = json!({
1285 "file": "target.md",
1286 "backlinks": [{"source": "a.md", "line": 5, "target": "target"}],
1287 "total": 1
1288 });
1289 let hints = generate_hints(&c, &data);
1290 assert!(
1291 hints
1292 .iter()
1293 .any(|h| h.cmd.contains("read") && h.cmd.contains("target.md")),
1294 "should suggest read target: {hints:?}"
1295 );
1296 assert!(
1297 hints
1298 .iter()
1299 .any(|h| h.cmd.contains("read") && h.cmd.contains("a.md")),
1300 "should suggest read first backlink source: {hints:?}"
1301 );
1302 }
1303
1304 #[test]
1305 fn create_index_hints_suggest_find_and_drop() {
1306 let c = ctx(HintSource::CreateIndex);
1307 let data = json!({"path": ".hyalo-index", "files_indexed": 42, "warnings": 0});
1308 let hints = generate_hints(&c, &data);
1309 assert!(
1310 hints
1311 .iter()
1312 .any(|h| h.cmd.contains("find") && h.cmd.contains("--index")),
1313 "should suggest find with index: {hints:?}"
1314 );
1315 assert!(
1316 hints.iter().any(|h| h.cmd.contains("drop-index")),
1317 "should suggest drop-index: {hints:?}"
1318 );
1319 }
1320
1321 #[test]
1322 fn drop_index_hints_suggest_create() {
1323 let c = ctx(HintSource::DropIndex);
1324 let data = json!({"deleted": ".hyalo-index"});
1325 let hints = generate_hints(&c, &data);
1326 assert!(
1327 hints.iter().any(|h| h.cmd.contains("create-index")),
1328 "should suggest create-index: {hints:?}"
1329 );
1330 }
1331
1332 #[test]
1333 fn mv_dry_run_hints_suggest_apply() {
1334 let c = ctx(HintSource::Mv);
1335 let data = json!({
1336 "from": "old.md",
1337 "to": "new.md",
1338 "dry_run": true,
1339 "updated_files": [],
1340 "total_files_updated": 0,
1341 "total_links_updated": 0
1342 });
1343 let hints = generate_hints(&c, &data);
1344 assert!(
1345 hints.iter().any(|h| h.cmd.contains("mv")
1346 && h.cmd.contains("new.md")
1347 && !h.cmd.contains("dry-run")),
1348 "should suggest applying the move: {hints:?}"
1349 );
1350 }
1351
1352 #[test]
1353 fn mv_applied_hints_suggest_read_and_backlinks() {
1354 let c = ctx(HintSource::Mv);
1355 let data = json!({
1356 "from": "old.md",
1357 "to": "new.md",
1358 "dry_run": false,
1359 "updated_files": [],
1360 "total_files_updated": 0,
1361 "total_links_updated": 0
1362 });
1363 let hints = generate_hints(&c, &data);
1364 assert!(
1365 hints
1366 .iter()
1367 .any(|h| h.cmd.contains("read") && h.cmd.contains("new.md")),
1368 "should suggest reading moved file: {hints:?}"
1369 );
1370 assert!(
1371 hints
1372 .iter()
1373 .any(|h| h.cmd.contains("backlinks") && h.cmd.contains("new.md")),
1374 "should suggest checking backlinks: {hints:?}"
1375 );
1376 }
1377
1378 #[test]
1379 fn task_read_undone_suggests_toggle() {
1380 let c = ctx(HintSource::TaskRead);
1381 let data =
1382 json!({"file": "todo.md", "line": 5, "status": " ", "text": "Fix bug", "done": false});
1383 let hints = generate_hints(&c, &data);
1384 assert!(
1385 hints.iter().any(|h| h.cmd.contains("task toggle")),
1386 "should suggest toggling undone task: {hints:?}"
1387 );
1388 }
1389
1390 #[test]
1391 fn task_read_done_omits_toggle() {
1392 let c = ctx(HintSource::TaskRead);
1393 let data =
1394 json!({"file": "todo.md", "line": 5, "status": "x", "text": "Fix bug", "done": true});
1395 let hints = generate_hints(&c, &data);
1396 assert!(
1397 !hints.iter().any(|h| h.cmd.contains("task toggle")),
1398 "should not suggest toggling already-done task: {hints:?}"
1399 );
1400 assert!(
1401 hints.iter().any(|h| h.cmd.contains("--task todo")),
1402 "should suggest viewing open tasks: {hints:?}"
1403 );
1404 }
1405
1406 #[test]
1407 fn task_mutation_hints_suggest_remaining_tasks() {
1408 let c = ctx(HintSource::TaskToggle);
1409 let data =
1410 json!({"file": "todo.md", "line": 5, "status": "x", "text": "Fix bug", "done": true});
1411 let hints = generate_hints(&c, &data);
1412 assert!(
1413 hints.iter().any(|h| h.cmd.contains("find")
1414 && h.cmd.contains("--task")
1415 && h.cmd.contains("todo")),
1416 "should suggest finding remaining tasks: {hints:?}"
1417 );
1418 }
1419
1420 #[test]
1421 fn links_fix_dry_run_hints_suggest_apply() {
1422 let c = ctx(HintSource::LinksFix);
1423 let data = json!({
1424 "broken": 5,
1425 "fixable": 3,
1426 "unfixable": 2,
1427 "applied": false,
1428 "fixes": []
1429 });
1430 let hints = generate_hints(&c, &data);
1431 assert!(
1432 hints.iter().any(|h| h.cmd.contains("links fix --apply")),
1433 "should suggest applying fixes: {hints:?}"
1434 );
1435 assert!(
1436 hints.iter().any(|h| h.cmd.contains("--broken-links")),
1437 "should suggest finding broken links: {hints:?}"
1438 );
1439 }
1440
1441 #[test]
1442 fn find_broad_query_suggests_summary() {
1443 let c = ctx(HintSource::Find);
1444 let items: Vec<serde_json::Value> = (0..15)
1446 .map(|i| make_find_item(&format!("{i}.md"), Some("completed"), &[]))
1447 .collect();
1448 let data = json!(items);
1449 let hints = generate_hints(&c, &data);
1450 assert!(
1451 hints.iter().any(|h| h.cmd.contains("summary")),
1452 "broad query should suggest summary: {hints:?}"
1453 );
1454 }
1455
1456 #[test]
1457 fn find_with_filters_does_not_suggest_summary() {
1458 let mut c = ctx(HintSource::Find);
1459 c.tag_filters = vec!["rust".to_owned()];
1460 let items: Vec<serde_json::Value> = (0..15)
1461 .map(|i| make_find_item(&format!("{i}.md"), Some("completed"), &["rust"]))
1462 .collect();
1463 let data = json!(items);
1464 let hints = generate_hints(&c, &data);
1465 assert!(
1466 !hints.iter().any(|h| h.cmd.contains("summary")),
1467 "filtered query should not suggest summary: {hints:?}"
1468 );
1469 }
1470
1471 #[test]
1472 fn find_suppresses_already_filtered_tag() {
1473 let mut c = ctx(HintSource::Find);
1474 c.tag_filters = vec!["rust".to_owned()];
1475 let items: Vec<serde_json::Value> = (0..10)
1476 .map(|i| make_find_item(&format!("{i}.md"), Some("planned"), &["rust", "cli"]))
1477 .collect();
1478 let data = json!(items);
1479 let hints = generate_hints(&c, &data);
1480 assert!(
1484 !hints
1485 .iter()
1486 .any(|h| h.description.starts_with("Narrow") && h.cmd.contains("--tag rust")),
1487 "should not suggest narrowing by already-filtered tag: {hints:?}"
1488 );
1489 assert!(
1490 hints.iter().any(|h| h.cmd.contains("--tag cli")),
1491 "should suggest non-filtered tag: {hints:?}"
1492 );
1493 }
1494
1495 #[test]
1496 fn summary_broken_links_suggests_links_fix() {
1497 let c = ctx(HintSource::Summary);
1498 let data = json!({
1499 "files": 10,
1500 "links": {"total": 20, "broken": 3},
1501 "properties": [],
1502 "tags": [],
1503 "status": [],
1504 "tasks": {"total": 0, "done": 0},
1505 "orphans": 0
1506 });
1507 let hints = generate_hints(&c, &data);
1508 assert!(
1509 hints.iter().any(|h| h.cmd.contains("links fix")),
1510 "summary with broken links should suggest links fix: {hints:?}"
1511 );
1512 assert!(
1513 hints.iter().any(|h| h.cmd.contains("--broken-links")),
1514 "summary with broken links should also suggest find --broken-links: {hints:?}"
1515 );
1516 }
1517
1518 #[test]
1519 fn summary_no_broken_links_omits_links_fix() {
1520 let c = ctx(HintSource::Summary);
1521 let data = json!({
1522 "files": 10,
1523 "links": {"total": 20, "broken": 0},
1524 "properties": [],
1525 "tags": [],
1526 "status": [],
1527 "tasks": {"total": 0, "done": 0},
1528 "orphans": 0
1529 });
1530 let hints = generate_hints(&c, &data);
1531 assert!(
1532 !hints.iter().any(|h| h.cmd.contains("links fix")),
1533 "summary without broken links should not suggest links fix: {hints:?}"
1534 );
1535 }
1536
1537 #[test]
1538 fn find_with_broken_links_suggests_links_fix() {
1539 let c = ctx(HintSource::Find);
1540 let item = json!({
1541 "file": "doc.md",
1542 "properties": {},
1543 "tags": [],
1544 "sections": [],
1545 "tasks": [],
1546 "links": [
1547 {"target": "existing.md", "path": "existing.md", "kind": "wiki"},
1548 {"target": "gone.md", "path": null, "kind": "wiki"}
1549 ],
1550 "modified": "2026-01-01T00:00:00Z"
1551 });
1552 let data = json!([item]);
1553 let hints = generate_hints(&c, &data);
1554 assert!(
1555 hints.iter().any(|h| h.cmd.contains("links fix")),
1556 "find results with broken links should suggest links fix: {hints:?}"
1557 );
1558 }
1559
1560 #[test]
1561 fn find_without_broken_links_omits_links_fix() {
1562 let c = ctx(HintSource::Find);
1563 let item = json!({
1564 "file": "doc.md",
1565 "properties": {},
1566 "tags": [],
1567 "sections": [],
1568 "tasks": [],
1569 "links": [
1570 {"target": "existing.md", "path": "existing.md", "kind": "wiki"}
1571 ],
1572 "modified": "2026-01-01T00:00:00Z"
1573 });
1574 let data = json!([item]);
1575 let hints = generate_hints(&c, &data);
1576 assert!(
1577 !hints.iter().any(|h| h.cmd.contains("links fix")),
1578 "find results without broken links should not suggest links fix: {hints:?}"
1579 );
1580 }
1581}