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 section_filters: Vec<String>,
71 pub view_name: Option<String>,
75 pub task_selector: Option<String>,
78 pub dry_run: bool,
80 pub index_path: Option<String>,
82}
83
84pub struct CommonHintFlags {
88 pub dir: Option<String>,
91 pub format: Option<String>,
93 pub hints: bool,
95}
96
97impl HintContext {
98 pub fn new(source: HintSource) -> Self {
99 Self {
100 source,
101 dir: None,
102 glob: vec![],
103 format: None,
104 hints: false,
105 fields: vec![],
106 sort: None,
107 has_limit: false,
108 has_body_search: false,
109 has_regex_search: false,
110 property_filters: vec![],
111 tag_filters: vec![],
112 task_filter: None,
113 file_targets: vec![],
114 section_filters: vec![],
115 view_name: None,
116 task_selector: None,
117 dry_run: false,
118 index_path: None,
119 }
120 }
121
122 pub fn from_common(source: HintSource, common: &CommonHintFlags) -> Self {
128 let mut ctx = Self::new(source);
129 ctx.dir.clone_from(&common.dir);
130 ctx.format.clone_from(&common.format);
131 ctx.hints = common.hints;
132 ctx
133 }
134}
135
136#[must_use]
141pub fn generate_hints(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
142 let hints = match &ctx.source {
143 HintSource::Summary => hints_for_summary(ctx, data),
144 HintSource::PropertiesSummary => hints_for_properties_summary(ctx, data),
145 HintSource::TagsSummary => hints_for_tags_summary(ctx, data),
146 HintSource::Find => hints_for_find(ctx, data),
147 HintSource::Set | HintSource::Remove | HintSource::Append => hints_for_mutation(ctx, data),
148 HintSource::Read => hints_for_read(ctx, data),
149 HintSource::Backlinks => hints_for_backlinks(ctx, data),
150 HintSource::Mv => hints_for_mv(ctx, data),
151 HintSource::TaskRead => hints_for_task_read(ctx, data),
152 HintSource::TaskToggle | HintSource::TaskSetStatus => hints_for_task_mutation(ctx, data),
153 HintSource::LinksFix => hints_for_links_fix(ctx, data),
154 HintSource::CreateIndex => hints_for_create_index(ctx, data),
155 HintSource::DropIndex => hints_for_drop_index(ctx, data),
156 };
157 hints.into_iter().take(MAX_HINTS).collect()
158}
159
160fn push_global_flags(parts: &mut Vec<String>, ctx: &HintContext) {
166 if let Some(dir) = &ctx.dir {
167 parts.push("--dir".to_owned());
168 parts.push(shell_quote(dir));
169 }
170 if let Some(fmt) = &ctx.format {
171 parts.push("--format".to_owned());
172 parts.push(shell_quote(fmt));
173 }
174 if ctx.hints {
175 parts.push("--hints".to_owned());
176 }
177}
178
179fn build_command_no_glob(ctx: &HintContext, args: &[&str]) -> String {
181 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
182 for arg in args {
183 parts.push(shell_quote(arg));
184 }
185 push_global_flags(&mut parts, ctx);
186 parts.join(" ")
187}
188
189fn build_command_with_file(
194 ctx: &HintContext,
195 subcommand_args: &[&str],
196 file_arg: &str,
197 trailing_args: &[&str],
198) -> String {
199 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
200 for arg in subcommand_args {
201 parts.push(shell_quote(arg));
202 }
203 push_file_positional(&mut parts, file_arg);
204 for arg in trailing_args {
205 parts.push(shell_quote(arg));
206 }
207 push_global_flags(&mut parts, ctx);
208 parts.join(" ")
209}
210
211fn build_command_with_glob(ctx: &HintContext, args: &[&str]) -> String {
213 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
214 for arg in args {
215 parts.push(shell_quote(arg));
216 }
217 push_global_flags(&mut parts, ctx);
218 for glob in &ctx.glob {
219 parts.push("--glob".to_owned());
220 parts.push(shell_quote(glob));
221 }
222 parts.join(" ")
223}
224
225fn build_find_command_preserving_filters(ctx: &HintContext, extra_args: &[&str]) -> String {
229 let mut parts: Vec<String> = vec!["hyalo".to_owned(), "find".to_owned()];
230 for pf in &ctx.property_filters {
231 parts.push("--property".to_owned());
232 parts.push(shell_quote(pf));
233 }
234 for tf in &ctx.tag_filters {
235 parts.push("--tag".to_owned());
236 parts.push(shell_quote(tf));
237 }
238 if let Some(task) = &ctx.task_filter {
239 parts.push("--task".to_owned());
240 parts.push(shell_quote(task));
241 }
242 for ft in &ctx.file_targets {
243 parts.push("--file".to_owned());
244 parts.push(shell_quote(ft));
245 }
246 for arg in extra_args {
247 parts.push(shell_quote(arg));
248 }
249 push_global_flags(&mut parts, ctx);
250 for glob in &ctx.glob {
251 parts.push("--glob".to_owned());
252 parts.push(shell_quote(glob));
253 }
254 parts.join(" ")
255}
256
257fn push_file_positional(parts: &mut Vec<String>, file: &str) {
262 if file.starts_with('-') {
263 parts.push("--file".to_owned());
264 parts.push(shell_quote(file));
265 } else {
266 parts.push(shell_quote(file));
267 }
268}
269
270pub fn shell_quote(s: &str) -> String {
275 if s.is_empty()
276 || s.chars().any(|c| {
277 !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '/' | ':' | '@' | '=' | ',' | '+')
278 })
279 {
280 format!("'{}'", s.replace('\'', "'\\''"))
283 } else {
284 s.to_owned()
285 }
286}
287
288fn status_priority(value: &str) -> u8 {
294 if value.eq_ignore_ascii_case("in-progress")
295 || value.eq_ignore_ascii_case("in progress")
296 || value.eq_ignore_ascii_case("active")
297 {
298 0
299 } else if value.eq_ignore_ascii_case("planned") || value.eq_ignore_ascii_case("todo") {
300 1
301 } else if value.eq_ignore_ascii_case("draft") || value.eq_ignore_ascii_case("idea") {
302 2
303 } else if value.eq_ignore_ascii_case("completed")
304 || value.eq_ignore_ascii_case("done")
305 || value.eq_ignore_ascii_case("archived")
306 {
307 4
308 } else {
309 3
310 }
311}
312
313fn first_modified_file(data: &serde_json::Value) -> Option<&str> {
319 fn extract(obj: &serde_json::Value) -> Option<&str> {
320 obj.get("modified")
321 .and_then(|m| m.as_array())
322 .and_then(|a| a.first())
323 .and_then(|f| f.as_str())
324 }
325 if let Some(arr) = data.as_array() {
326 arr.iter().find_map(extract)
327 } else {
328 extract(data)
329 }
330}
331
332fn hints_for_summary(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
337 let mut hints = Vec::new();
338
339 hints.push(Hint::new(
340 "Browse property names and types",
341 build_command_with_glob(ctx, &["properties"]),
342 ));
343 hints.push(Hint::new(
344 "Browse tags and their counts",
345 build_command_with_glob(ctx, &["tags"]),
346 ));
347
348 let tasks_total = data
350 .get("tasks")
351 .and_then(|t| t.get("total"))
352 .and_then(serde_json::Value::as_u64)
353 .unwrap_or(0);
354 let tasks_done = data
355 .get("tasks")
356 .and_then(|t| t.get("done"))
357 .and_then(serde_json::Value::as_u64)
358 .unwrap_or(0);
359 if tasks_total > tasks_done {
360 hints.push(Hint::new(
361 "Find files with open tasks",
362 build_command_with_glob(ctx, &["find", "--task", "todo"]),
363 ));
364 }
365
366 let broken_links = data
368 .get("links")
369 .and_then(|l| l.get("broken"))
370 .and_then(serde_json::Value::as_u64)
371 .unwrap_or(0);
372 if broken_links > 0 {
373 let remaining = MAX_HINTS.saturating_sub(hints.len());
374 if remaining > 0 {
375 hints.push(Hint::new(
376 "List files with broken links",
377 build_command_with_glob(ctx, &["find", "--broken-links"]),
378 ));
379 }
380 let remaining = MAX_HINTS.saturating_sub(hints.len());
381 if remaining > 0 {
382 hints.push(Hint::new(
383 "Auto-fix broken links (dry run)",
384 build_command_with_glob(ctx, &["links", "fix"]),
385 ));
386 }
387 }
388
389 if let Some(status_arr) = data.get("status").and_then(|s| s.as_array()) {
391 let mut groups: Vec<(&str, u8)> = status_arr
392 .iter()
393 .filter_map(|g| {
394 let value = g.get("value").and_then(|v| v.as_str())?;
395 Some((value, status_priority(value)))
396 })
397 .collect();
398 groups.sort_by_key(|&(_, p)| p);
399
400 let remaining = MAX_HINTS.saturating_sub(hints.len());
401 for (value, _) in groups.into_iter().take(remaining.min(2)) {
402 let filter = format!("status={value}");
403 hints.push(Hint::new(
404 format!("Filter by status: {value}"),
405 build_command_no_glob(ctx, &["find", "--property", &filter]),
406 ));
407 }
408 }
409
410 hints
411}
412
413fn hints_for_properties_summary(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
414 let Some(arr) = data.as_array() else {
415 return vec![];
416 };
417
418 let mut entries: Vec<(&str, u64)> = arr
420 .iter()
421 .filter_map(|e| {
422 let name = e.get("name").and_then(|n| n.as_str())?;
423 let count = e
424 .get("count")
425 .and_then(serde_json::Value::as_u64)
426 .unwrap_or(0);
427 Some((name, count))
428 })
429 .collect();
430 entries.sort_by(|a, b| b.1.cmp(&a.1));
431
432 entries
433 .into_iter()
434 .take(3)
435 .map(|(name, count)| {
436 Hint::new(
437 format!("Find {count} files with property: {name}"),
438 build_command_with_glob(ctx, &["find", "--property", name]),
439 )
440 })
441 .collect()
442}
443
444fn slugify(s: &str) -> String {
447 let mut out = String::with_capacity(s.len());
448 for ch in s.chars() {
449 if ch.is_ascii_alphanumeric() || ch == '_' {
450 out.push(ch.to_ascii_lowercase());
451 } else {
452 if !out.ends_with('-') {
454 out.push('-');
455 }
456 }
457 }
458 out.trim_matches('-').to_owned()
459}
460
461fn auto_view_name(ctx: &HintContext) -> String {
463 let mut parts: Vec<String> = Vec::new();
464
465 for pf in &ctx.property_filters {
466 if let Some(pos) = pf.find("~=") {
467 let key = &pf[..pos];
469 parts.push(key.to_lowercase());
470 } else if let Some(pos) = pf.find('=') {
471 let val = &pf[pos + 1..];
472 if !val.is_empty() {
473 parts.push(val.to_lowercase());
474 }
475 } else if let Some(stripped) = pf.strip_prefix('!') {
476 parts.push(format!("no-{stripped}"));
477 }
478 }
479
480 for tf in &ctx.tag_filters {
481 parts.push(tf.to_lowercase());
482 }
483
484 if let Some(task) = &ctx.task_filter {
485 parts.push(task.to_lowercase());
486 }
487
488 let slug = slugify(&parts.join("-"));
489 let truncated: String = slug.chars().take(40).collect();
490 let trimmed = truncated.trim_end_matches('-');
492 if trimmed.is_empty() {
493 "my-view".to_owned()
494 } else {
495 trimmed.to_owned()
496 }
497}
498
499fn build_views_set_command(ctx: &HintContext, view_name: &str) -> String {
501 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
502 push_global_flags(&mut parts, ctx);
503 parts.push("views".to_owned());
504 parts.push("set".to_owned());
505 parts.push(shell_quote(view_name));
506 for pf in &ctx.property_filters {
507 parts.push("--property".to_owned());
508 parts.push(shell_quote(pf));
509 }
510 for tf in &ctx.tag_filters {
511 parts.push("--tag".to_owned());
512 parts.push(shell_quote(tf));
513 }
514 if let Some(task) = &ctx.task_filter {
515 parts.push("--task".to_owned());
516 parts.push(shell_quote(task));
517 }
518 parts.join(" ")
519}
520
521fn suggest_save_as_view(ctx: &HintContext) -> Option<Hint> {
526 if ctx.view_name.is_some() {
527 return None;
528 }
529
530 let filter_count =
534 ctx.property_filters.len() + ctx.tag_filters.len() + usize::from(ctx.task_filter.is_some());
535
536 if filter_count < 2 {
537 return None;
538 }
539
540 let name = auto_view_name(ctx);
541 let cmd = build_views_set_command(ctx, &name);
542 Some(Hint::new("Save this query as a view", cmd))
543}
544
545fn hints_for_find(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
546 let Some(results) = data.as_array() else {
548 return vec![];
549 };
550
551 if results.is_empty() {
552 return vec![];
553 }
554
555 let mut hints = Vec::new();
556 let result_count = results.len();
557 let is_single = result_count == 1;
558
559 if let Some(first_file) = results[0].get("file").and_then(|f| f.as_str()) {
561 hints.push(Hint::new(
562 "Read this file's content",
563 build_command_with_file(ctx, &["read"], first_file, &[]),
564 ));
565 if is_single {
566 hints.push(Hint::new(
567 "See all metadata for this file",
568 build_command_no_glob(ctx, &["find", "--file", first_file, "--fields", "all"]),
569 ));
570 }
571 hints.push(Hint::new(
572 "See what links to this file",
573 build_command_with_file(ctx, &["backlinks"], first_file, &[]),
574 ));
575 }
576
577 if ctx.file_targets.len() == 1 {
580 let file = &ctx.file_targets[0];
581 let has_open_tasks = results.iter().any(|item| {
582 item.get("tasks")
583 .and_then(|t| t.as_array())
584 .is_some_and(|tasks| {
585 tasks
586 .iter()
587 .any(|t| t.get("done") == Some(&serde_json::Value::Bool(false)))
588 })
589 });
590 if has_open_tasks {
591 let remaining = MAX_HINTS.saturating_sub(hints.len());
592 if remaining > 0 {
593 if let Some(section) = ctx.section_filters.first() {
594 hints.push(Hint::new(
595 format!("Toggle all tasks in section \"{section}\""),
596 build_command_with_file(
597 ctx,
598 &["task", "toggle"],
599 file,
600 &["--section", section],
601 ),
602 ));
603 } else {
604 hints.push(Hint::new(
605 "Toggle all tasks in this file",
606 build_command_with_file(ctx, &["task", "toggle"], file, &["--all"]),
607 ));
608 }
609 }
610 }
611 }
612
613 let has_no_filters = ctx.property_filters.is_empty()
615 && ctx.tag_filters.is_empty()
616 && ctx.task_filter.is_none()
617 && !ctx.has_body_search
618 && !ctx.has_regex_search
619 && ctx.file_targets.is_empty();
620
621 if has_no_filters && result_count > 10 {
622 hints.push(Hint::new(
623 if ctx.glob.is_empty() {
624 "Get a high-level vault overview"
625 } else {
626 "Get stats for this file set"
627 },
628 build_command_with_glob(ctx, &["summary"]),
629 ));
630 }
631
632 if result_count > 5 {
634 let mut tag_counts: std::collections::HashMap<&str, usize> =
636 std::collections::HashMap::new();
637 for item in results {
638 if let Some(tags) = item.get("tags").and_then(|t| t.as_array()) {
639 for tag in tags {
640 if let Some(name) = tag.as_str()
641 && !ctx.tag_filters.iter().any(|t| t == name)
642 {
643 *tag_counts.entry(name).or_insert(0) += 1;
644 }
645 }
646 }
647 }
648
649 let mut status_counts: std::collections::HashMap<&str, usize> =
652 std::collections::HashMap::new();
653 for item in results {
654 let Some(status_val) = item.get("properties").and_then(|p| p.get("status")) else {
655 continue;
656 };
657 let iter: Box<dyn Iterator<Item = &str>> = match status_val {
659 serde_json::Value::String(s) => Box::new(std::iter::once(s.as_str())),
660 serde_json::Value::Array(arr) => Box::new(arr.iter().filter_map(|v| v.as_str())),
661 _ => Box::new(std::iter::empty()),
662 };
663 for status in iter {
664 let already_filtered = ctx
665 .property_filters
666 .iter()
667 .any(|f| f == &format!("status={status}"));
668 if !already_filtered {
669 *status_counts.entry(status).or_insert(0) += 1;
670 }
671 }
672 }
673
674 if let Some((top_tag, count)) = tag_counts
677 .iter()
678 .max_by(|(a_tag, a_cnt), (b_tag, b_cnt)| a_cnt.cmp(b_cnt).then(b_tag.cmp(a_tag)))
679 {
680 let remaining = MAX_HINTS.saturating_sub(hints.len());
681 if remaining > 0 {
682 hints.push(Hint::new(
683 format!("Narrow by tag: {top_tag} ({count} files)"),
684 build_command_with_glob(ctx, &["find", "--tag", top_tag]),
685 ));
686 }
687 }
688
689 let mut status_vec: Vec<(&str, usize, u8)> = status_counts
691 .iter()
692 .map(|(v, c)| (*v, *c, status_priority(v)))
693 .collect();
694 status_vec.sort_by(|a, b| a.2.cmp(&b.2).then(b.1.cmp(&a.1)).then(a.0.cmp(b.0)));
696
697 if let Some((top_status, count, _)) = status_vec.first() {
698 let remaining = MAX_HINTS.saturating_sub(hints.len());
699 if remaining > 0 {
700 hints.push(Hint::new(
701 format!("Filter by status: {top_status} ({count} files)"),
702 build_command_with_glob(
703 ctx,
704 &["find", "--property", &format!("status={top_status}")],
705 ),
706 ));
707 }
708 }
709
710 if ctx.sort.is_none() {
712 let remaining = MAX_HINTS.saturating_sub(hints.len());
713 if remaining > 0 {
714 hints.push(Hint::new(
715 "Sort by most recently modified",
716 build_find_command_preserving_filters(
717 ctx,
718 &["--sort", "modified", "--reverse"],
719 ),
720 ));
721 }
722 }
723
724 if !ctx.has_limit {
726 let remaining = MAX_HINTS.saturating_sub(hints.len());
727 if remaining > 0 {
728 hints.push(Hint::new(
729 "Limit to 10 results",
730 build_find_command_preserving_filters(ctx, &["--limit", "10"]),
731 ));
732 }
733 }
734 }
735
736 if let Some(view_hint) = suggest_save_as_view(ctx) {
738 let remaining = MAX_HINTS.saturating_sub(hints.len());
739 if remaining > 0 {
740 hints.push(view_hint);
741 }
742 }
743
744 let has_broken_links = results.iter().any(|item| {
751 item.get("links")
752 .and_then(|l| l.as_array())
753 .is_some_and(|links| {
754 links
755 .iter()
756 .any(|link| link.get("path").is_some_and(serde_json::Value::is_null))
757 })
758 });
759 if has_broken_links {
760 let remaining = MAX_HINTS.saturating_sub(hints.len());
761 if remaining > 0 {
762 hints.push(Hint::new(
763 "Auto-fix broken links (dry run)",
764 build_command_with_glob(ctx, &["links", "fix"]),
765 ));
766 }
767 }
768
769 hints
770}
771
772fn hints_for_tags_summary(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
773 let Some(tags_arr) = data.as_array() else {
775 return vec![];
776 };
777
778 let mut entries: Vec<(&str, u64)> = tags_arr
780 .iter()
781 .filter_map(|entry| {
782 let name = entry.get("name").and_then(|n| n.as_str())?;
783 let count = entry
784 .get("count")
785 .and_then(serde_json::Value::as_u64)
786 .unwrap_or(0);
787 Some((name, count))
788 })
789 .collect();
790 entries.sort_by(|a, b| b.1.cmp(&a.1));
791
792 entries
793 .into_iter()
794 .take(3)
795 .map(|(name, count)| {
796 Hint::new(
797 format!("Find {count} files tagged: {name}"),
798 build_command_with_glob(ctx, &["find", "--tag", name]),
799 )
800 })
801 .collect()
802}
803
804fn hints_for_mutation(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
805 let mut hints = Vec::new();
806
807 let first_modified = first_modified_file(data);
808
809 if let Some(file) = first_modified {
810 hints.push(Hint::new(
811 "Verify the updated file",
812 build_command_no_glob(
813 ctx,
814 &["find", "--file", file, "--fields", "properties,tags"],
815 ),
816 ));
817 hints.push(Hint::new(
818 "Read the modified file",
819 build_command_no_glob(ctx, &["read", file]),
820 ));
821 }
822
823 hints
824}
825
826fn hints_for_read(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
827 let mut hints = Vec::new();
828
829 let file = data
830 .get("file")
831 .and_then(|f| f.as_str())
832 .or_else(|| ctx.file_targets.first().map(String::as_str));
833
834 if let Some(file) = file {
835 hints.push(Hint::new(
836 "See metadata for this file",
837 build_command_no_glob(ctx, &["find", "--file", file, "--fields", "all"]),
838 ));
839 hints.push(Hint::new(
840 "See what links to this file",
841 build_command_with_file(ctx, &["backlinks"], file, &[]),
842 ));
843 }
844
845 hints
846}
847
848fn hints_for_backlinks(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
849 let mut hints = Vec::new();
850
851 let file = data.get("file").and_then(|f| f.as_str());
852
853 if let Some(file) = file {
854 hints.push(Hint::new(
855 "Read this file's content",
856 build_command_with_file(ctx, &["read"], file, &[]),
857 ));
858 hints.push(Hint::new(
859 "See this file's outgoing links",
860 build_command_no_glob(ctx, &["find", "--file", file, "--fields", "links"]),
861 ));
862 }
863
864 if let Some(backlinks) = data.get("backlinks").and_then(|b| b.as_array())
866 && let Some(first_source) = backlinks
867 .first()
868 .and_then(|b| b.get("source"))
869 .and_then(|s| s.as_str())
870 {
871 hints.push(Hint::new(
872 format!("Read linking file: {first_source}"),
873 build_command_with_file(ctx, &["read"], first_source, &[]),
874 ));
875 }
876
877 hints
878}
879
880fn hints_for_mv(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
881 let mut hints = Vec::new();
882
883 let to_path = data.get("to").and_then(|t| t.as_str());
884 let is_dry_run = data
885 .get("dry_run")
886 .and_then(serde_json::Value::as_bool)
887 .unwrap_or(false);
888
889 if let Some(to_path) = to_path {
890 if is_dry_run {
891 if let Some(from_path) = data.get("from").and_then(|f| f.as_str()) {
892 hints.push(Hint::new(
893 "Apply this move",
894 build_command_with_file(ctx, &["mv"], from_path, &["--to", to_path]),
895 ));
896 }
897 } else {
898 hints.push(Hint::new(
899 "Read the moved file",
900 build_command_with_file(ctx, &["read"], to_path, &[]),
901 ));
902 hints.push(Hint::new(
903 "Verify backlinks updated",
904 build_command_with_file(ctx, &["backlinks"], to_path, &[]),
905 ));
906 }
907 }
908
909 hints
910}
911
912fn task_result_has_open(data: &serde_json::Value) -> bool {
914 if let Some(arr) = data.as_array() {
916 return arr
917 .iter()
918 .any(|t| t.get("done") == Some(&serde_json::Value::Bool(false)));
919 }
920 data.get("done") == Some(&serde_json::Value::Bool(false))
922}
923
924fn hints_for_task_read(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
926 let mut hints = Vec::new();
927
928 if let Some(selector) = &ctx.task_selector {
930 if let Some(file) = ctx.file_targets.first() {
931 let has_open = task_result_has_open(data);
932 if has_open {
933 if selector == "all" {
934 hints.push(Hint::new(
935 "Toggle all tasks in this file",
936 build_command_with_file(ctx, &["task", "toggle"], file, &["--all"]),
937 ));
938 } else if let Some(section) = selector.strip_prefix("section:") {
939 hints.push(Hint::new(
940 format!("Toggle all tasks in section \"{section}\""),
941 build_command_with_file(
942 ctx,
943 &["task", "toggle"],
944 file,
945 &["--section", section],
946 ),
947 ));
948 }
949 }
950 }
951 if selector != "lines" {
955 return hints;
956 }
957 }
958
959 let file = data.get("file").and_then(|f| f.as_str());
961 let line = data.get("line").and_then(serde_json::Value::as_u64);
962 let done = data
963 .get("done")
964 .and_then(serde_json::Value::as_bool)
965 .unwrap_or(false);
966
967 if let (Some(file), Some(line)) = (file, line) {
968 let line_str = line.to_string();
969 if !done {
970 hints.push(Hint::new(
971 "Toggle this task to done",
972 build_command_with_file(ctx, &["task", "toggle"], file, &["--line", &line_str]),
973 ));
974 }
975 hints.push(Hint::new(
976 "See all open tasks in this file",
977 build_command_no_glob(
978 ctx,
979 &[
980 "find", "--file", file, "--task", "todo", "--fields", "tasks",
981 ],
982 ),
983 ));
984 }
985
986 hints
987}
988
989fn hints_for_task_mutation(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
990 let mut hints = Vec::new();
991
992 let file = ctx
993 .file_targets
994 .first()
995 .map(String::as_str)
996 .or_else(|| data.get("file").and_then(|f| f.as_str()));
997
998 if let Some(file) = file {
999 if let Some(selector) = &ctx.task_selector {
1001 if selector == "all" {
1002 hints.push(Hint::new(
1003 "Read all tasks in this file",
1004 build_command_with_file(ctx, &["task", "read"], file, &["--all"]),
1005 ));
1006 } else if let Some(section) = selector.strip_prefix("section:") {
1007 hints.push(Hint::new(
1008 format!("Read tasks in section \"{section}\""),
1009 build_command_with_file(ctx, &["task", "read"], file, &["--section", section]),
1010 ));
1011 }
1012 }
1013
1014 hints.push(Hint::new(
1015 "See remaining open tasks",
1016 build_command_no_glob(
1017 ctx,
1018 &[
1019 "find", "--file", file, "--task", "todo", "--fields", "tasks",
1020 ],
1021 ),
1022 ));
1023 hints.push(Hint::new(
1024 "Read the file",
1025 build_command_with_file(ctx, &["read"], file, &[]),
1026 ));
1027 }
1028
1029 hints
1030}
1031
1032fn hints_for_links_fix(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1033 let mut hints = Vec::new();
1034
1035 let is_dry_run = !data
1036 .get("applied")
1037 .and_then(serde_json::Value::as_bool)
1038 .unwrap_or(false);
1039 let fixable = data
1040 .get("fixable")
1041 .and_then(serde_json::Value::as_u64)
1042 .unwrap_or(0);
1043 let unfixable = data
1044 .get("unfixable")
1045 .and_then(serde_json::Value::as_u64)
1046 .unwrap_or(0);
1047
1048 if is_dry_run && fixable > 0 {
1049 hints.push(Hint::new(
1050 format!("Apply {fixable} fixes"),
1051 build_command_with_glob(ctx, &["links", "fix", "--apply"]),
1052 ));
1053 }
1054
1055 if unfixable > 0 {
1056 hints.push(Hint::new(
1057 "List files with remaining broken links",
1058 build_command_with_glob(ctx, &["find", "--broken-links"]),
1059 ));
1060 }
1061
1062 hints
1063}
1064
1065fn hints_for_create_index(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1066 let mut hints = Vec::new();
1067
1068 let index_path = data
1069 .get("path")
1070 .and_then(|p| p.as_str())
1071 .or(ctx.index_path.as_deref())
1072 .unwrap_or(".hyalo-index");
1073
1074 hints.push(Hint::new(
1075 "Query using the index",
1076 build_command_no_glob(ctx, &["find", "--index", index_path]),
1077 ));
1078 hints.push(Hint::new(
1079 "Delete the index when done",
1080 build_command_no_glob(ctx, &["drop-index"]),
1081 ));
1082
1083 hints
1084}
1085
1086fn hints_for_drop_index(ctx: &HintContext, _data: &serde_json::Value) -> Vec<Hint> {
1087 vec![Hint::new(
1088 "Rebuild the index",
1089 build_command_no_glob(ctx, &["create-index"]),
1090 )]
1091}
1092
1093#[cfg(test)]
1094mod tests {
1095 use super::*;
1096 use serde_json::json;
1097
1098 fn ctx(source: HintSource) -> HintContext {
1099 HintContext::new(source)
1100 }
1101
1102 fn ctx_with_dir(source: HintSource, dir: &str) -> HintContext {
1103 let mut ctx = HintContext::new(source);
1104 ctx.dir = Some(dir.to_owned());
1105 ctx
1106 }
1107
1108 fn ctx_with_glob(source: HintSource, glob: &str) -> HintContext {
1109 let mut ctx = HintContext::new(source);
1110 ctx.glob = vec![glob.to_owned()];
1111 ctx
1112 }
1113
1114 #[test]
1117 fn shell_quote_plain_string() {
1118 assert_eq!(shell_quote("status"), "status");
1119 }
1120
1121 #[test]
1122 fn shell_quote_string_with_space() {
1123 assert_eq!(shell_quote("in progress"), "'in progress'");
1124 }
1125
1126 #[test]
1127 fn shell_quote_string_with_special_chars() {
1128 assert_eq!(shell_quote("foo$bar"), "'foo$bar'");
1129 }
1130
1131 #[test]
1132 fn shell_quote_string_with_single_quote() {
1133 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1134 }
1135
1136 #[test]
1137 fn shell_quote_glob_chars() {
1138 assert_eq!(shell_quote("**/*.md"), "'**/*.md'");
1139 }
1140
1141 #[test]
1142 fn shell_quote_empty_string() {
1143 assert_eq!(shell_quote(""), "''");
1144 }
1145
1146 #[test]
1149 fn build_command_no_flags() {
1150 let c = ctx(HintSource::Summary);
1151 assert_eq!(
1152 build_command_no_glob(&c, &["properties"]),
1153 "hyalo properties"
1154 );
1155 }
1156
1157 #[test]
1158 fn build_command_with_dir() {
1159 let c = ctx_with_dir(HintSource::Summary, "/my/vault");
1160 assert_eq!(
1161 build_command_no_glob(&c, &["tags"]),
1162 "hyalo tags --dir /my/vault"
1163 );
1164 }
1165
1166 #[test]
1167 fn build_command_with_glob_propagated() {
1168 let c = ctx_with_glob(HintSource::PropertiesSummary, "**/*.md");
1169 assert_eq!(
1170 build_command_with_glob(&c, &["properties"]),
1171 "hyalo properties --glob '**/*.md'"
1172 );
1173 }
1174
1175 #[test]
1178 fn status_priority_ordering() {
1179 assert!(status_priority("in-progress") < status_priority("planned"));
1180 assert!(status_priority("planned") < status_priority("draft"));
1181 assert!(status_priority("draft") < status_priority("custom"));
1182 assert!(status_priority("custom") < status_priority("completed"));
1183 }
1184
1185 #[test]
1188 fn summary_always_includes_properties_and_tags() {
1189 let c = ctx(HintSource::Summary);
1190 let data = json!({
1191 "files": {"total": 10, "by_directory": []},
1192 "properties": [],
1193 "tags": {"tags": [], "total": 0},
1194 "status": [],
1195 "tasks": {"total": 0, "done": 0},
1196 "recent_files": []
1197 });
1198 let hints = generate_hints(&c, &data);
1199 assert!(hints.iter().any(|h| {
1200 h.cmd == "hyalo properties"
1201 || (h.cmd.starts_with("hyalo properties ") && h.cmd.contains("--dir "))
1202 || (h.cmd.starts_with("hyalo properties ") && h.cmd.contains("--glob "))
1203 }));
1204 assert!(hints.iter().any(|h| {
1205 h.cmd == "hyalo tags"
1206 || (h.cmd.starts_with("hyalo tags ") && h.cmd.contains("--dir "))
1207 || (h.cmd.starts_with("hyalo tags ") && h.cmd.contains("--glob "))
1208 }));
1209 }
1210
1211 #[test]
1212 fn summary_suggests_tasks_todo_when_open_tasks() {
1213 let c = ctx(HintSource::Summary);
1214 let data = json!({
1215 "files": {"total": 5, "by_directory": []},
1216 "properties": [],
1217 "tags": {"tags": [], "total": 0},
1218 "status": [],
1219 "tasks": {"total": 10, "done": 3},
1220 "recent_files": []
1221 });
1222 let hints = generate_hints(&c, &data);
1223 assert!(
1224 hints.iter().any(|h| h.cmd.contains("find")
1225 && h.cmd.contains("--task")
1226 && h.cmd.contains("todo"))
1227 );
1228 }
1229
1230 #[test]
1231 fn summary_omits_tasks_todo_when_all_done() {
1232 let c = ctx(HintSource::Summary);
1233 let data = json!({
1234 "files": {"total": 5, "by_directory": []},
1235 "properties": [],
1236 "tags": {"tags": [], "total": 0},
1237 "status": [],
1238 "tasks": {"total": 10, "done": 10},
1239 "recent_files": []
1240 });
1241 let hints = generate_hints(&c, &data);
1242 assert!(!hints.iter().any(|h| h.cmd.contains("--todo")));
1243 }
1244
1245 #[test]
1246 fn summary_picks_interesting_status_values() {
1247 let c = ctx(HintSource::Summary);
1248 let data = json!({
1249 "files": {"total": 5, "by_directory": []},
1250 "properties": [],
1251 "tags": {"tags": [], "total": 0},
1252 "status": [
1253 {"value": "completed", "files": ["a.md"]},
1254 {"value": "in-progress", "files": ["b.md"]},
1255 {"value": "planned", "files": ["c.md"]}
1256 ],
1257 "tasks": {"total": 0, "done": 0},
1258 "recent_files": []
1259 });
1260 let hints = generate_hints(&c, &data);
1261 let in_progress_pos = hints.iter().position(|h| h.cmd.contains("in-progress"));
1263 let completed_pos = hints.iter().position(|h| h.cmd.contains("completed"));
1264 assert!(in_progress_pos.is_some(), "should suggest in-progress");
1265 if let Some(cp) = completed_pos {
1267 assert!(in_progress_pos.unwrap() < cp);
1268 }
1269 }
1270
1271 #[test]
1272 fn summary_max_hints_not_exceeded() {
1273 let c = ctx(HintSource::Summary);
1274 let data = json!({
1275 "files": {"total": 5, "by_directory": []},
1276 "properties": [],
1277 "tags": {"tags": [], "total": 0},
1278 "status": [
1279 {"value": "in-progress", "files": ["a.md"]},
1280 {"value": "planned", "files": ["b.md"]},
1281 {"value": "draft", "files": ["c.md"]},
1282 {"value": "idea", "files": ["d.md"]}
1283 ],
1284 "tasks": {"total": 5, "done": 1},
1285 "recent_files": []
1286 });
1287 let hints = generate_hints(&c, &data);
1288 assert!(hints.len() <= MAX_HINTS);
1289 }
1290
1291 #[test]
1294 fn properties_summary_top3_by_count() {
1295 let c = ctx(HintSource::PropertiesSummary);
1296 let data = json!([
1297 {"name": "title", "type": "text", "count": 100},
1298 {"name": "status", "type": "text", "count": 50},
1299 {"name": "tags", "type": "list", "count": 30},
1300 {"name": "author", "type": "text", "count": 5}
1301 ]);
1302 let hints = generate_hints(&c, &data);
1303 assert_eq!(hints.len(), 3);
1304 assert!(hints[0].cmd.contains("title"));
1305 assert!(hints[1].cmd.contains("status"));
1306 assert!(hints[2].cmd.contains("tags"));
1307 assert!(!hints.iter().any(|h| h.cmd.contains("author")));
1309 }
1310
1311 #[test]
1312 fn properties_summary_empty_data() {
1313 let c = ctx(HintSource::PropertiesSummary);
1314 let hints = generate_hints(&c, &json!([]));
1315 assert!(hints.is_empty());
1316 }
1317
1318 #[test]
1319 fn properties_summary_propagates_glob() {
1320 let c = ctx_with_glob(HintSource::PropertiesSummary, "notes/*.md");
1321 let data = json!([{"name": "status", "type": "text", "count": 5}]);
1322 let hints = generate_hints(&c, &data);
1323 assert!(hints[0].cmd.contains("--glob"));
1324 assert!(hints[0].cmd.contains("notes/*.md"));
1325 }
1326
1327 fn make_find_item(file: &str, status: Option<&str>, tags: &[&str]) -> serde_json::Value {
1330 let mut props = serde_json::Map::new();
1331 if let Some(s) = status {
1332 props.insert("status".to_owned(), serde_json::Value::String(s.to_owned()));
1333 }
1334 json!({
1335 "file": file,
1336 "properties": props,
1337 "tags": tags,
1338 "sections": [],
1339 "tasks": [],
1340 "links": [],
1341 "modified": "2026-01-01T00:00:00Z"
1342 })
1343 }
1344
1345 #[test]
1346 fn find_empty_results_no_hints() {
1347 let c = ctx(HintSource::Find);
1348 let hints = generate_hints(&c, &json!([]));
1349 assert!(hints.is_empty());
1350 }
1351
1352 #[test]
1353 fn find_single_result_suggests_read_and_backlinks() {
1354 let c = ctx(HintSource::Find);
1355 let items = vec![make_find_item("notes/alpha.md", None, &[])];
1356 let data = json!(items);
1357 let hints = generate_hints(&c, &data);
1358 assert!(
1359 hints
1360 .iter()
1361 .any(|h| h.cmd.contains("read") && h.cmd.contains("alpha.md")),
1362 "should suggest read: {hints:?}"
1363 );
1364 assert!(
1365 hints
1366 .iter()
1367 .any(|h| h.cmd.contains("backlinks") && h.cmd.contains("alpha.md")),
1368 "should suggest backlinks: {hints:?}"
1369 );
1370 }
1371
1372 #[test]
1373 fn find_many_results_suggests_top_tag() {
1374 let c = ctx(HintSource::Find);
1375 let items = vec![
1377 make_find_item("a.md", Some("planned"), &["rust", "cli"]),
1378 make_find_item("b.md", Some("planned"), &["rust"]),
1379 make_find_item("c.md", Some("in-progress"), &["rust"]),
1380 make_find_item("d.md", Some("completed"), &["rust"]),
1381 make_find_item("e.md", Some("completed"), &["cli"]),
1382 make_find_item("f.md", Some("completed"), &[]),
1383 ];
1384 let data = json!(items);
1385 let hints = generate_hints(&c, &data);
1386 assert!(
1387 hints
1388 .iter()
1389 .any(|h| h.cmd.contains("--tag") && h.cmd.contains("rust")),
1390 "should suggest --tag rust (most common): {hints:?}"
1391 );
1392 }
1393
1394 #[test]
1395 fn find_many_results_suggests_interesting_status() {
1396 let c = ctx(HintSource::Find);
1397 let items = vec![
1399 make_find_item("a.md", Some("in-progress"), &[]),
1400 make_find_item("b.md", Some("completed"), &[]),
1401 make_find_item("c.md", Some("completed"), &[]),
1402 make_find_item("d.md", Some("completed"), &[]),
1403 make_find_item("e.md", Some("completed"), &[]),
1404 make_find_item("f.md", Some("completed"), &[]),
1405 ];
1406 let data = json!(items);
1407 let hints = generate_hints(&c, &data);
1408 assert!(
1409 hints
1410 .iter()
1411 .any(|h| h.cmd.contains("--property") && h.cmd.contains("status=in-progress")),
1412 "should prefer in-progress status: {hints:?}"
1413 );
1414 }
1415
1416 #[test]
1417 fn find_many_results_no_tags_falls_back_to_status() {
1418 let c = ctx(HintSource::Find);
1419 let items = vec![
1421 make_find_item("a.md", Some("planned"), &[]),
1422 make_find_item("b.md", Some("planned"), &[]),
1423 make_find_item("c.md", Some("planned"), &[]),
1424 make_find_item("d.md", Some("planned"), &[]),
1425 make_find_item("e.md", Some("planned"), &[]),
1426 make_find_item("f.md", Some("planned"), &[]),
1427 ];
1428 let data = json!(items);
1429 let hints = generate_hints(&c, &data);
1430 assert!(
1431 hints
1432 .iter()
1433 .any(|h| h.cmd.contains("--property") && h.cmd.contains("status=planned")),
1434 "should suggest status filter: {hints:?}"
1435 );
1436 assert!(
1438 !hints.iter().any(|h| h.cmd.contains("--tag")),
1439 "should not suggest --tag when no tags: {hints:?}"
1440 );
1441 }
1442
1443 #[test]
1444 fn find_hints_never_exceed_max() {
1445 let c = ctx(HintSource::Find);
1446 let items: Vec<serde_json::Value> = (0..10)
1448 .map(|i| make_find_item(&format!("{i}.md"), Some("planned"), &["rust", "cli"]))
1449 .collect();
1450 let data = json!(items);
1451 let hints = generate_hints(&c, &data);
1452 assert!(hints.len() <= MAX_HINTS);
1453 }
1454
1455 #[test]
1456 fn find_sort_hint_preserves_existing_filters() {
1457 let mut c = ctx(HintSource::Find);
1458 c.property_filters = vec!["status=draft".to_owned()];
1459 c.tag_filters = vec!["research".to_owned()];
1460 let items: Vec<serde_json::Value> = (0..6)
1462 .map(|i| make_find_item(&format!("{i}.md"), Some("draft"), &["research"]))
1463 .collect();
1464 let data = json!(items);
1465 let hints = generate_hints(&c, &data);
1466 let sort_hint = hints.iter().find(|h| h.cmd.contains("--sort"));
1467 assert!(sort_hint.is_some(), "should include a sort hint: {hints:?}");
1468 let cmd = &sort_hint.unwrap().cmd;
1469 assert!(
1470 cmd.contains("--property status=draft"),
1471 "sort hint should preserve --property filter: {cmd}"
1472 );
1473 assert!(
1474 cmd.contains("--tag research"),
1475 "sort hint should preserve --tag filter: {cmd}"
1476 );
1477 }
1478
1479 #[test]
1480 fn find_limit_hint_preserves_existing_filters() {
1481 let mut c = ctx(HintSource::Find);
1482 c.tag_filters = vec!["iteration".to_owned()];
1483 let items: Vec<serde_json::Value> = (0..6)
1484 .map(|i| make_find_item(&format!("{i}.md"), Some("planned"), &["iteration"]))
1485 .collect();
1486 let data = json!(items);
1487 let hints = generate_hints(&c, &data);
1488 let limit_hint = hints.iter().find(|h| h.cmd.contains("--limit"));
1489 assert!(
1490 limit_hint.is_some(),
1491 "should include a limit hint: {hints:?}"
1492 );
1493 let cmd = &limit_hint.unwrap().cmd;
1494 assert!(
1495 cmd.contains("--tag iteration"),
1496 "limit hint should preserve --tag filter: {cmd}"
1497 );
1498 }
1499
1500 #[test]
1503 fn dir_flag_propagated_to_all_hints() {
1504 let c = ctx_with_dir(HintSource::TagsSummary, "/vault");
1505 let data = json!([{"name": "rust", "count": 5}]);
1507 let hints = generate_hints(&c, &data);
1508 assert!(hints[0].cmd.contains("--dir"));
1509 assert!(hints[0].cmd.contains("/vault"));
1510 }
1511
1512 #[test]
1515 fn mutation_hints_suggest_verify_and_read() {
1516 let c = ctx(HintSource::Set);
1517 let data = json!({
1518 "property": "status",
1519 "value": "completed",
1520 "modified": ["notes/alpha.md"],
1521 "skipped": [],
1522 "total": 1
1523 });
1524 let hints = generate_hints(&c, &data);
1525 assert!(
1526 hints
1527 .iter()
1528 .any(|h| h.cmd.contains("find") && h.cmd.contains("alpha.md")),
1529 "should suggest verify: {hints:?}"
1530 );
1531 assert!(
1532 hints
1533 .iter()
1534 .any(|h| h.cmd.contains("read") && h.cmd.contains("alpha.md")),
1535 "should suggest read: {hints:?}"
1536 );
1537 }
1538
1539 #[test]
1540 fn read_hints_suggest_metadata_and_backlinks() {
1541 let c = ctx(HintSource::Read);
1542 let data = json!({"file": "notes/alpha.md", "content": "Some content"});
1543 let hints = generate_hints(&c, &data);
1544 assert!(
1545 hints
1546 .iter()
1547 .any(|h| h.cmd.contains("find") && h.cmd.contains("alpha.md")),
1548 "should suggest find: {hints:?}"
1549 );
1550 assert!(
1551 hints
1552 .iter()
1553 .any(|h| h.cmd.contains("backlinks") && h.cmd.contains("alpha.md")),
1554 "should suggest backlinks: {hints:?}"
1555 );
1556 }
1557
1558 #[test]
1559 fn backlinks_hints_suggest_read_and_outgoing() {
1560 let c = ctx(HintSource::Backlinks);
1561 let data = json!({
1562 "file": "target.md",
1563 "backlinks": [{"source": "a.md", "line": 5, "target": "target"}],
1564 "total": 1
1565 });
1566 let hints = generate_hints(&c, &data);
1567 assert!(
1568 hints
1569 .iter()
1570 .any(|h| h.cmd.contains("read") && h.cmd.contains("target.md")),
1571 "should suggest read target: {hints:?}"
1572 );
1573 assert!(
1574 hints
1575 .iter()
1576 .any(|h| h.cmd.contains("read") && h.cmd.contains("a.md")),
1577 "should suggest read first backlink source: {hints:?}"
1578 );
1579 }
1580
1581 #[test]
1582 fn create_index_hints_suggest_find_and_drop() {
1583 let c = ctx(HintSource::CreateIndex);
1584 let data = json!({"path": ".hyalo-index", "files_indexed": 42, "warnings": 0});
1585 let hints = generate_hints(&c, &data);
1586 assert!(
1587 hints
1588 .iter()
1589 .any(|h| h.cmd.contains("find") && h.cmd.contains("--index")),
1590 "should suggest find with index: {hints:?}"
1591 );
1592 assert!(
1593 hints.iter().any(|h| h.cmd.contains("drop-index")),
1594 "should suggest drop-index: {hints:?}"
1595 );
1596 }
1597
1598 #[test]
1599 fn drop_index_hints_suggest_create() {
1600 let c = ctx(HintSource::DropIndex);
1601 let data = json!({"deleted": ".hyalo-index"});
1602 let hints = generate_hints(&c, &data);
1603 assert!(
1604 hints.iter().any(|h| h.cmd.contains("create-index")),
1605 "should suggest create-index: {hints:?}"
1606 );
1607 }
1608
1609 #[test]
1610 fn mv_dry_run_hints_suggest_apply() {
1611 let c = ctx(HintSource::Mv);
1612 let data = json!({
1613 "from": "old.md",
1614 "to": "new.md",
1615 "dry_run": true,
1616 "updated_files": [],
1617 "total_files_updated": 0,
1618 "total_links_updated": 0
1619 });
1620 let hints = generate_hints(&c, &data);
1621 assert!(
1622 hints.iter().any(|h| h.cmd.contains("mv")
1623 && h.cmd.contains("new.md")
1624 && !h.cmd.contains("dry-run")),
1625 "should suggest applying the move: {hints:?}"
1626 );
1627 }
1628
1629 #[test]
1630 fn mv_applied_hints_suggest_read_and_backlinks() {
1631 let c = ctx(HintSource::Mv);
1632 let data = json!({
1633 "from": "old.md",
1634 "to": "new.md",
1635 "dry_run": false,
1636 "updated_files": [],
1637 "total_files_updated": 0,
1638 "total_links_updated": 0
1639 });
1640 let hints = generate_hints(&c, &data);
1641 assert!(
1642 hints
1643 .iter()
1644 .any(|h| h.cmd.contains("read") && h.cmd.contains("new.md")),
1645 "should suggest reading moved file: {hints:?}"
1646 );
1647 assert!(
1648 hints
1649 .iter()
1650 .any(|h| h.cmd.contains("backlinks") && h.cmd.contains("new.md")),
1651 "should suggest checking backlinks: {hints:?}"
1652 );
1653 }
1654
1655 #[test]
1656 fn task_read_undone_suggests_toggle() {
1657 let c = ctx(HintSource::TaskRead);
1658 let data =
1659 json!({"file": "todo.md", "line": 5, "status": " ", "text": "Fix bug", "done": false});
1660 let hints = generate_hints(&c, &data);
1661 assert!(
1662 hints.iter().any(|h| h.cmd.contains("task toggle")),
1663 "should suggest toggling undone task: {hints:?}"
1664 );
1665 }
1666
1667 #[test]
1668 fn task_read_done_omits_toggle() {
1669 let c = ctx(HintSource::TaskRead);
1670 let data =
1671 json!({"file": "todo.md", "line": 5, "status": "x", "text": "Fix bug", "done": true});
1672 let hints = generate_hints(&c, &data);
1673 assert!(
1674 !hints.iter().any(|h| h.cmd.contains("task toggle")),
1675 "should not suggest toggling already-done task: {hints:?}"
1676 );
1677 assert!(
1678 hints.iter().any(|h| h.cmd.contains("--task todo")),
1679 "should suggest viewing open tasks: {hints:?}"
1680 );
1681 }
1682
1683 #[test]
1684 fn task_mutation_hints_suggest_remaining_tasks() {
1685 let c = ctx(HintSource::TaskToggle);
1686 let data =
1687 json!({"file": "todo.md", "line": 5, "status": "x", "text": "Fix bug", "done": true});
1688 let hints = generate_hints(&c, &data);
1689 assert!(
1690 hints.iter().any(|h| h.cmd.contains("find")
1691 && h.cmd.contains("--task")
1692 && h.cmd.contains("todo")),
1693 "should suggest finding remaining tasks: {hints:?}"
1694 );
1695 }
1696
1697 #[test]
1698 fn links_fix_dry_run_hints_suggest_apply() {
1699 let c = ctx(HintSource::LinksFix);
1700 let data = json!({
1701 "broken": 5,
1702 "fixable": 3,
1703 "unfixable": 2,
1704 "applied": false,
1705 "fixes": []
1706 });
1707 let hints = generate_hints(&c, &data);
1708 assert!(
1709 hints.iter().any(|h| h.cmd.contains("links fix --apply")),
1710 "should suggest applying fixes: {hints:?}"
1711 );
1712 assert!(
1713 hints.iter().any(|h| h.cmd.contains("--broken-links")),
1714 "should suggest finding broken links: {hints:?}"
1715 );
1716 }
1717
1718 #[test]
1719 fn find_broad_query_suggests_summary() {
1720 let c = ctx(HintSource::Find);
1721 let items: Vec<serde_json::Value> = (0..15)
1723 .map(|i| make_find_item(&format!("{i}.md"), Some("completed"), &[]))
1724 .collect();
1725 let data = json!(items);
1726 let hints = generate_hints(&c, &data);
1727 assert!(
1728 hints.iter().any(|h| h.cmd.contains("summary")),
1729 "broad query should suggest summary: {hints:?}"
1730 );
1731 }
1732
1733 #[test]
1734 fn find_with_filters_does_not_suggest_summary() {
1735 let mut c = ctx(HintSource::Find);
1736 c.tag_filters = vec!["rust".to_owned()];
1737 let items: Vec<serde_json::Value> = (0..15)
1738 .map(|i| make_find_item(&format!("{i}.md"), Some("completed"), &["rust"]))
1739 .collect();
1740 let data = json!(items);
1741 let hints = generate_hints(&c, &data);
1742 assert!(
1743 !hints.iter().any(|h| h.cmd.contains("summary")),
1744 "filtered query should not suggest summary: {hints:?}"
1745 );
1746 }
1747
1748 #[test]
1749 fn find_suppresses_already_filtered_tag() {
1750 let mut c = ctx(HintSource::Find);
1751 c.tag_filters = vec!["rust".to_owned()];
1752 let items: Vec<serde_json::Value> = (0..10)
1753 .map(|i| make_find_item(&format!("{i}.md"), Some("planned"), &["rust", "cli"]))
1754 .collect();
1755 let data = json!(items);
1756 let hints = generate_hints(&c, &data);
1757 assert!(
1761 !hints
1762 .iter()
1763 .any(|h| h.description.starts_with("Narrow") && h.cmd.contains("--tag rust")),
1764 "should not suggest narrowing by already-filtered tag: {hints:?}"
1765 );
1766 assert!(
1767 hints.iter().any(|h| h.cmd.contains("--tag cli")),
1768 "should suggest non-filtered tag: {hints:?}"
1769 );
1770 }
1771
1772 #[test]
1773 fn summary_broken_links_suggests_links_fix() {
1774 let c = ctx(HintSource::Summary);
1775 let data = json!({
1776 "files": 10,
1777 "links": {"total": 20, "broken": 3},
1778 "properties": [],
1779 "tags": [],
1780 "status": [],
1781 "tasks": {"total": 0, "done": 0},
1782 "orphans": 0
1783 });
1784 let hints = generate_hints(&c, &data);
1785 assert!(
1786 hints.iter().any(|h| h.cmd.contains("links fix")),
1787 "summary with broken links should suggest links fix: {hints:?}"
1788 );
1789 assert!(
1790 hints.iter().any(|h| h.cmd.contains("--broken-links")),
1791 "summary with broken links should also suggest find --broken-links: {hints:?}"
1792 );
1793 }
1794
1795 #[test]
1796 fn summary_no_broken_links_omits_links_fix() {
1797 let c = ctx(HintSource::Summary);
1798 let data = json!({
1799 "files": 10,
1800 "links": {"total": 20, "broken": 0},
1801 "properties": [],
1802 "tags": [],
1803 "status": [],
1804 "tasks": {"total": 0, "done": 0},
1805 "orphans": 0
1806 });
1807 let hints = generate_hints(&c, &data);
1808 assert!(
1809 !hints.iter().any(|h| h.cmd.contains("links fix")),
1810 "summary without broken links should not suggest links fix: {hints:?}"
1811 );
1812 }
1813
1814 #[test]
1815 fn find_with_broken_links_suggests_links_fix() {
1816 let c = ctx(HintSource::Find);
1817 let item = json!({
1818 "file": "doc.md",
1819 "properties": {},
1820 "tags": [],
1821 "sections": [],
1822 "tasks": [],
1823 "links": [
1824 {"target": "existing.md", "path": "existing.md", "kind": "wiki"},
1825 {"target": "gone.md", "path": null, "kind": "wiki"}
1826 ],
1827 "modified": "2026-01-01T00:00:00Z"
1828 });
1829 let data = json!([item]);
1830 let hints = generate_hints(&c, &data);
1831 assert!(
1832 hints.iter().any(|h| h.cmd.contains("links fix")),
1833 "find results with broken links should suggest links fix: {hints:?}"
1834 );
1835 }
1836
1837 #[test]
1838 fn find_without_broken_links_omits_links_fix() {
1839 let c = ctx(HintSource::Find);
1840 let item = json!({
1841 "file": "doc.md",
1842 "properties": {},
1843 "tags": [],
1844 "sections": [],
1845 "tasks": [],
1846 "links": [
1847 {"target": "existing.md", "path": "existing.md", "kind": "wiki"}
1848 ],
1849 "modified": "2026-01-01T00:00:00Z"
1850 });
1851 let data = json!([item]);
1852 let hints = generate_hints(&c, &data);
1853 assert!(
1854 !hints.iter().any(|h| h.cmd.contains("links fix")),
1855 "find results without broken links should not suggest links fix: {hints:?}"
1856 );
1857 }
1858}