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 Lint,
45 Types { subcommand: Option<String> },
46}
47
48pub struct HintContext {
54 pub source: HintSource,
55 pub dir: Option<String>,
57 pub glob: Vec<String>,
58 pub format: Option<String>,
60 pub hints: bool,
62 pub fields: Vec<String>,
64 pub sort: Option<String>,
65 pub has_limit: bool,
66 pub has_body_search: bool,
67 pub body_pattern: Option<String>,
69 pub has_regex_search: bool,
70 pub property_filters: Vec<String>,
71 pub tag_filters: Vec<String>,
72 pub task_filter: Option<String>,
73 pub file_targets: Vec<String>,
74 pub section_filters: Vec<String>,
75 pub view_name: Option<String>,
79 pub task_selector: Option<String>,
82 pub dry_run: bool,
84 pub index_path: Option<String>,
86}
87
88pub struct CommonHintFlags {
92 pub dir: Option<String>,
95 pub format: Option<String>,
97 pub hints: bool,
99}
100
101impl HintContext {
102 pub fn new(source: HintSource) -> Self {
103 Self {
104 source,
105 dir: None,
106 glob: vec![],
107 format: None,
108 hints: false,
109 fields: vec![],
110 sort: None,
111 has_limit: false,
112 has_body_search: false,
113 body_pattern: None,
114 has_regex_search: false,
115 property_filters: vec![],
116 tag_filters: vec![],
117 task_filter: None,
118 file_targets: vec![],
119 section_filters: vec![],
120 view_name: None,
121 task_selector: None,
122 dry_run: false,
123 index_path: None,
124 }
125 }
126
127 pub fn from_common(source: HintSource, common: &CommonHintFlags) -> Self {
133 let mut ctx = Self::new(source);
134 ctx.dir.clone_from(&common.dir);
135 ctx.format.clone_from(&common.format);
136 ctx.hints = common.hints;
137 ctx
138 }
139}
140
141#[must_use]
146pub fn generate_hints(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
147 let hints = match &ctx.source {
148 HintSource::Summary => hints_for_summary(ctx, data),
149 HintSource::PropertiesSummary => hints_for_properties_summary(ctx, data),
150 HintSource::TagsSummary => hints_for_tags_summary(ctx, data),
151 HintSource::Find => hints_for_find(ctx, data),
152 HintSource::Set | HintSource::Remove | HintSource::Append => hints_for_mutation(ctx, data),
153 HintSource::Read => hints_for_read(ctx, data),
154 HintSource::Backlinks => hints_for_backlinks(ctx, data),
155 HintSource::Mv => hints_for_mv(ctx, data),
156 HintSource::TaskRead => hints_for_task_read(ctx, data),
157 HintSource::TaskToggle | HintSource::TaskSetStatus => hints_for_task_mutation(ctx, data),
158 HintSource::LinksFix => hints_for_links_fix(ctx, data),
159 HintSource::CreateIndex => hints_for_create_index(ctx, data),
160 HintSource::DropIndex => hints_for_drop_index(ctx, data),
161 HintSource::Lint => hints_for_lint(ctx, data),
162 HintSource::Types { .. } => hints_for_types(ctx, data),
163 };
164 hints.into_iter().take(MAX_HINTS).collect()
165}
166
167fn push_global_flags(parts: &mut Vec<String>, ctx: &HintContext) {
173 if let Some(dir) = &ctx.dir {
174 parts.push("--dir".to_owned());
175 parts.push(shell_quote(dir));
176 }
177 if let Some(fmt) = &ctx.format {
178 parts.push("--format".to_owned());
179 parts.push(shell_quote(fmt));
180 }
181 if ctx.hints {
182 parts.push("--hints".to_owned());
183 }
184}
185
186fn build_command_no_glob(ctx: &HintContext, args: &[&str]) -> String {
188 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
189 for arg in args {
190 parts.push(shell_quote(arg));
191 }
192 push_global_flags(&mut parts, ctx);
193 parts.join(" ")
194}
195
196fn build_command_with_file(
201 ctx: &HintContext,
202 subcommand_args: &[&str],
203 file_arg: &str,
204 trailing_args: &[&str],
205) -> String {
206 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
207 for arg in subcommand_args {
208 parts.push(shell_quote(arg));
209 }
210 push_file_positional(&mut parts, file_arg);
211 for arg in trailing_args {
212 parts.push(shell_quote(arg));
213 }
214 push_global_flags(&mut parts, ctx);
215 parts.join(" ")
216}
217
218fn build_command_with_glob(ctx: &HintContext, args: &[&str]) -> String {
220 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
221 for arg in args {
222 parts.push(shell_quote(arg));
223 }
224 push_global_flags(&mut parts, ctx);
225 for glob in &ctx.glob {
226 parts.push("--glob".to_owned());
227 parts.push(shell_quote(glob));
228 }
229 parts.join(" ")
230}
231
232fn build_command_with_glob_and_files(ctx: &HintContext, args: &[&str]) -> String {
236 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
237 for arg in args {
238 parts.push(shell_quote(arg));
239 }
240 push_global_flags(&mut parts, ctx);
241 for glob in &ctx.glob {
242 parts.push("--glob".to_owned());
243 parts.push(shell_quote(glob));
244 }
245 for ft in &ctx.file_targets {
246 parts.push(shell_quote(ft));
247 }
248 parts.join(" ")
249}
250
251fn build_find_command_preserving_filters(ctx: &HintContext, extra_args: &[&str]) -> String {
255 let mut parts: Vec<String> = vec!["hyalo".to_owned(), "find".to_owned()];
256 for pf in &ctx.property_filters {
257 parts.push("--property".to_owned());
258 parts.push(shell_quote(pf));
259 }
260 for tf in &ctx.tag_filters {
261 parts.push("--tag".to_owned());
262 parts.push(shell_quote(tf));
263 }
264 if let Some(task) = &ctx.task_filter {
265 parts.push("--task".to_owned());
266 parts.push(shell_quote(task));
267 }
268 for ft in &ctx.file_targets {
269 parts.push("--file".to_owned());
270 parts.push(shell_quote(ft));
271 }
272 for arg in extra_args {
273 parts.push(shell_quote(arg));
274 }
275 push_global_flags(&mut parts, ctx);
276 for glob in &ctx.glob {
277 parts.push("--glob".to_owned());
278 parts.push(shell_quote(glob));
279 }
280 parts.join(" ")
281}
282
283fn build_find_command_with_pattern(ctx: &HintContext, new_pattern: &str) -> String {
287 let mut parts: Vec<String> = vec!["hyalo".to_owned(), "find".to_owned()];
288 parts.push(shell_quote(new_pattern));
289 for pf in &ctx.property_filters {
290 parts.push("--property".to_owned());
291 parts.push(shell_quote(pf));
292 }
293 for tf in &ctx.tag_filters {
294 parts.push("--tag".to_owned());
295 parts.push(shell_quote(tf));
296 }
297 if let Some(task) = &ctx.task_filter {
298 parts.push("--task".to_owned());
299 parts.push(shell_quote(task));
300 }
301 for ft in &ctx.file_targets {
302 parts.push("--file".to_owned());
303 parts.push(shell_quote(ft));
304 }
305 push_global_flags(&mut parts, ctx);
306 for glob in &ctx.glob {
307 parts.push("--glob".to_owned());
308 parts.push(shell_quote(glob));
309 }
310 parts.join(" ")
311}
312
313fn push_file_positional(parts: &mut Vec<String>, file: &str) {
318 if file.starts_with('-') {
319 parts.push("--file".to_owned());
320 parts.push(shell_quote(file));
321 } else {
322 parts.push(shell_quote(file));
323 }
324}
325
326pub fn shell_quote(s: &str) -> String {
331 if s.is_empty()
332 || s.chars().any(|c| {
333 !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '/' | ':' | '@' | '=' | ',' | '+')
334 })
335 {
336 format!("'{}'", s.replace('\'', "'\\''"))
339 } else {
340 s.to_owned()
341 }
342}
343
344fn status_priority(value: &str) -> u8 {
350 if value.eq_ignore_ascii_case("in-progress")
351 || value.eq_ignore_ascii_case("in progress")
352 || value.eq_ignore_ascii_case("active")
353 {
354 0
355 } else if value.eq_ignore_ascii_case("planned") || value.eq_ignore_ascii_case("todo") {
356 1
357 } else if value.eq_ignore_ascii_case("draft") || value.eq_ignore_ascii_case("idea") {
358 2
359 } else if value.eq_ignore_ascii_case("completed")
360 || value.eq_ignore_ascii_case("done")
361 || value.eq_ignore_ascii_case("archived")
362 {
363 4
364 } else {
365 3
366 }
367}
368
369fn first_modified_file(data: &serde_json::Value) -> Option<&str> {
375 fn extract(obj: &serde_json::Value) -> Option<&str> {
376 obj.get("modified")
377 .and_then(|m| m.as_array())
378 .and_then(|a| a.first())
379 .and_then(|f| f.as_str())
380 }
381 if let Some(arr) = data.as_array() {
382 arr.iter().find_map(extract)
383 } else {
384 extract(data)
385 }
386}
387
388fn hints_for_summary(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
393 let mut hints = Vec::new();
394
395 hints.push(Hint::new(
396 "Browse property names and types",
397 build_command_with_glob(ctx, &["properties"]),
398 ));
399 hints.push(Hint::new(
400 "Browse tags and their counts",
401 build_command_with_glob(ctx, &["tags"]),
402 ));
403
404 if let Some(schema_obj) = data.get("schema") {
407 let errors = schema_obj
408 .get("errors")
409 .and_then(serde_json::Value::as_u64)
410 .unwrap_or(0);
411 let warnings = schema_obj
412 .get("warnings")
413 .and_then(serde_json::Value::as_u64)
414 .unwrap_or(0);
415 if (errors > 0 || warnings > 0) && hints.len() < MAX_HINTS {
416 hints.push(Hint::new(
417 format!("Lint: {errors} errors, {warnings} warnings"),
418 build_command_with_glob(ctx, &["lint"]),
419 ));
420 }
421 }
422
423 let tasks_total = data
425 .get("tasks")
426 .and_then(|t| t.get("total"))
427 .and_then(serde_json::Value::as_u64)
428 .unwrap_or(0);
429 let tasks_done = data
430 .get("tasks")
431 .and_then(|t| t.get("done"))
432 .and_then(serde_json::Value::as_u64)
433 .unwrap_or(0);
434 if tasks_total > tasks_done {
435 hints.push(Hint::new(
436 "Find files with open tasks",
437 build_command_with_glob(ctx, &["find", "--task", "todo"]),
438 ));
439 }
440
441 let orphan_count = data
443 .get("orphans")
444 .and_then(serde_json::Value::as_u64)
445 .unwrap_or(0);
446 if orphan_count > 0 && hints.len() < MAX_HINTS {
447 hints.push(Hint::new(
448 format!("{orphan_count} orphan files"),
449 build_command_with_glob(ctx, &["find", "--orphan"]),
450 ));
451 }
452
453 let dead_end_count = data
455 .get("dead_ends")
456 .and_then(serde_json::Value::as_u64)
457 .unwrap_or(0);
458 if dead_end_count > 0 && hints.len() < MAX_HINTS {
459 hints.push(Hint::new(
460 format!("{dead_end_count} dead-end files"),
461 build_command_with_glob(ctx, &["find", "--dead-end"]),
462 ));
463 }
464
465 let broken_links = data
467 .get("links")
468 .and_then(|l| l.get("broken"))
469 .and_then(serde_json::Value::as_u64)
470 .unwrap_or(0);
471 if broken_links > 0 && hints.len() < MAX_HINTS {
472 hints.push(Hint::new(
473 format!("{broken_links} broken links"),
474 build_command_with_glob(ctx, &["find", "--broken-links"]),
475 ));
476 if hints.len() < MAX_HINTS {
477 hints.push(Hint::new(
478 "Auto-fix broken links (dry run)",
479 build_command_with_glob(ctx, &["links", "fix"]),
480 ));
481 }
482 }
483
484 if let Some(schema_obj) = data.get("schema") {
487 let errors = schema_obj
488 .get("errors")
489 .and_then(serde_json::Value::as_u64)
490 .unwrap_or(0);
491 let warnings = schema_obj
492 .get("warnings")
493 .and_then(serde_json::Value::as_u64)
494 .unwrap_or(0);
495 if errors == 0 && warnings == 0 && hints.len() < MAX_HINTS {
496 hints.push(Hint::new(
497 "Validate frontmatter against schema",
498 build_command_with_glob(ctx, &["lint"]),
499 ));
500 }
501 if hints.len() < MAX_HINTS {
502 hints.push(Hint::new(
503 "Manage type schemas",
504 build_command_no_glob(ctx, &["types", "list"]),
505 ));
506 }
507 }
508
509 if let Some(status_arr) = data.get("status").and_then(|s| s.as_array()) {
511 let mut groups: Vec<(&str, u8)> = status_arr
512 .iter()
513 .filter_map(|g| {
514 let value = g.get("value").and_then(|v| v.as_str())?;
515 Some((value, status_priority(value)))
516 })
517 .collect();
518 groups.sort_by_key(|&(_, p)| p);
519
520 let remaining = MAX_HINTS.saturating_sub(hints.len());
521 for (value, _) in groups.into_iter().take(remaining.min(2)) {
522 let filter = format!("status={value}");
523 hints.push(Hint::new(
524 format!("Filter by status: {value}"),
525 build_command_no_glob(ctx, &["find", "--property", &filter]),
526 ));
527 }
528 }
529
530 hints
531}
532
533fn hints_for_properties_summary(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
534 let Some(arr) = data.as_array() else {
535 return vec![];
536 };
537
538 let mut entries: Vec<(&str, u64)> = arr
540 .iter()
541 .filter_map(|e| {
542 let name = e.get("name").and_then(|n| n.as_str())?;
543 let count = e
544 .get("count")
545 .and_then(serde_json::Value::as_u64)
546 .unwrap_or(0);
547 Some((name, count))
548 })
549 .collect();
550 entries.sort_by(|a, b| b.1.cmp(&a.1));
551
552 entries
553 .into_iter()
554 .take(3)
555 .map(|(name, count)| {
556 Hint::new(
557 format!("Find {count} files with property: {name}"),
558 build_command_with_glob(ctx, &["find", "--property", name]),
559 )
560 })
561 .collect()
562}
563
564fn slugify(s: &str) -> String {
567 let mut out = String::with_capacity(s.len());
568 for ch in s.chars() {
569 if ch.is_ascii_alphanumeric() || ch == '_' {
570 out.push(ch.to_ascii_lowercase());
571 } else {
572 if !out.ends_with('-') {
574 out.push('-');
575 }
576 }
577 }
578 out.trim_matches('-').to_owned()
579}
580
581fn auto_view_name(ctx: &HintContext) -> String {
583 let mut parts: Vec<String> = Vec::new();
584
585 for pf in &ctx.property_filters {
586 if let Some(pos) = pf.find("~=") {
587 let key = &pf[..pos];
589 parts.push(key.to_lowercase());
590 } else if let Some(pos) = pf.find('=') {
591 let val = &pf[pos + 1..];
592 if !val.is_empty() {
593 parts.push(val.to_lowercase());
594 }
595 } else if let Some(stripped) = pf.strip_prefix('!') {
596 parts.push(format!("no-{stripped}"));
597 }
598 }
599
600 for tf in &ctx.tag_filters {
601 parts.push(tf.to_lowercase());
602 }
603
604 if let Some(task) = &ctx.task_filter {
605 parts.push(task.to_lowercase());
606 }
607
608 let slug = slugify(&parts.join("-"));
609 let truncated: String = slug.chars().take(40).collect();
610 let trimmed = truncated.trim_end_matches('-');
612 if trimmed.is_empty() {
613 "my-view".to_owned()
614 } else {
615 trimmed.to_owned()
616 }
617}
618
619fn build_views_set_command(ctx: &HintContext, view_name: &str) -> String {
621 let mut parts: Vec<String> = vec!["hyalo".to_owned()];
622 push_global_flags(&mut parts, ctx);
623 parts.push("views".to_owned());
624 parts.push("set".to_owned());
625 parts.push(shell_quote(view_name));
626 for pf in &ctx.property_filters {
627 parts.push("--property".to_owned());
628 parts.push(shell_quote(pf));
629 }
630 for tf in &ctx.tag_filters {
631 parts.push("--tag".to_owned());
632 parts.push(shell_quote(tf));
633 }
634 if let Some(task) = &ctx.task_filter {
635 parts.push("--task".to_owned());
636 parts.push(shell_quote(task));
637 }
638 parts.join(" ")
639}
640
641fn suggest_save_as_view(ctx: &HintContext) -> Option<Hint> {
646 if ctx.view_name.is_some() {
647 return None;
648 }
649
650 let filter_count =
654 ctx.property_filters.len() + ctx.tag_filters.len() + usize::from(ctx.task_filter.is_some());
655
656 if filter_count < 2 {
657 return None;
658 }
659
660 let name = auto_view_name(ctx);
661 let cmd = build_views_set_command(ctx, &name);
662 Some(Hint::new("Save this query as a view", cmd))
663}
664
665fn hints_for_find(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
666 let Some(results) = data.as_array() else {
668 return vec![];
669 };
670
671 if results.is_empty() {
672 if let Some(pat) = &ctx.body_pattern {
676 let has_quotes = pat.contains('"');
677 let words: Vec<&str> = pat
678 .split_whitespace()
679 .filter(|w| {
680 !w.starts_with('-')
681 && !w.eq_ignore_ascii_case("or")
682 && !w.eq_ignore_ascii_case("and")
683 })
684 .collect();
685 if !has_quotes && words.len() >= 2 {
686 let or_query = words.join(" OR ");
687 return vec![Hint::new(
688 "Try OR instead of AND (match any word)",
689 build_find_command_with_pattern(ctx, &or_query),
690 )];
691 }
692 }
693 return vec![];
694 }
695
696 let mut hints = Vec::new();
697 let result_count = results.len();
698 let is_single = result_count == 1;
699
700 if let Some(first_file) = results[0].get("file").and_then(|f| f.as_str()) {
702 hints.push(Hint::new(
703 "Read this file's content",
704 build_command_with_file(ctx, &["read"], first_file, &[]),
705 ));
706 if is_single {
707 hints.push(Hint::new(
708 "See all metadata for this file",
709 build_command_no_glob(ctx, &["find", "--file", first_file, "--fields", "all"]),
710 ));
711 }
712 hints.push(Hint::new(
713 "See what links to this file",
714 build_command_with_file(ctx, &["backlinks"], first_file, &[]),
715 ));
716 }
717
718 if ctx.file_targets.len() == 1 {
721 let file = &ctx.file_targets[0];
722 let has_open_tasks = results.iter().any(|item| {
723 item.get("tasks")
724 .and_then(|t| t.as_array())
725 .is_some_and(|tasks| {
726 tasks
727 .iter()
728 .any(|t| t.get("done") == Some(&serde_json::Value::Bool(false)))
729 })
730 });
731 if has_open_tasks {
732 let remaining = MAX_HINTS.saturating_sub(hints.len());
733 if remaining > 0 {
734 if let Some(section) = ctx.section_filters.first() {
735 hints.push(Hint::new(
736 format!("Toggle all tasks in section \"{section}\""),
737 build_command_with_file(
738 ctx,
739 &["task", "toggle"],
740 file,
741 &["--section", section],
742 ),
743 ));
744 } else {
745 hints.push(Hint::new(
746 "Toggle all tasks in this file",
747 build_command_with_file(ctx, &["task", "toggle"], file, &["--all"]),
748 ));
749 }
750 }
751 }
752 }
753
754 let has_no_filters = ctx.property_filters.is_empty()
756 && ctx.tag_filters.is_empty()
757 && ctx.task_filter.is_none()
758 && !ctx.has_body_search
759 && !ctx.has_regex_search
760 && ctx.file_targets.is_empty();
761
762 if has_no_filters && result_count > 10 {
763 hints.push(Hint::new(
764 if ctx.glob.is_empty() {
765 "Get a high-level vault overview"
766 } else {
767 "Get stats for this file set"
768 },
769 build_command_with_glob(ctx, &["summary"]),
770 ));
771 }
772
773 if result_count > 5 {
775 let mut tag_counts: std::collections::HashMap<&str, usize> =
777 std::collections::HashMap::new();
778 for item in results {
779 if let Some(tags) = item.get("tags").and_then(|t| t.as_array()) {
780 for tag in tags {
781 if let Some(name) = tag.as_str()
782 && !ctx.tag_filters.iter().any(|t| t == name)
783 {
784 *tag_counts.entry(name).or_insert(0) += 1;
785 }
786 }
787 }
788 }
789
790 let mut status_counts: std::collections::HashMap<&str, usize> =
793 std::collections::HashMap::new();
794 for item in results {
795 let Some(status_val) = item.get("properties").and_then(|p| p.get("status")) else {
796 continue;
797 };
798 let iter: Box<dyn Iterator<Item = &str>> = match status_val {
800 serde_json::Value::String(s) => Box::new(std::iter::once(s.as_str())),
801 serde_json::Value::Array(arr) => Box::new(arr.iter().filter_map(|v| v.as_str())),
802 _ => Box::new(std::iter::empty()),
803 };
804 for status in iter {
805 let already_filtered = ctx
806 .property_filters
807 .iter()
808 .any(|f| f == &format!("status={status}"));
809 if !already_filtered {
810 *status_counts.entry(status).or_insert(0) += 1;
811 }
812 }
813 }
814
815 if let Some((top_tag, count)) = tag_counts
818 .iter()
819 .max_by(|(a_tag, a_cnt), (b_tag, b_cnt)| a_cnt.cmp(b_cnt).then(b_tag.cmp(a_tag)))
820 {
821 let remaining = MAX_HINTS.saturating_sub(hints.len());
822 if remaining > 0 {
823 hints.push(Hint::new(
824 format!("Narrow by tag: {top_tag} ({count} files)"),
825 build_command_with_glob(ctx, &["find", "--tag", top_tag]),
826 ));
827 }
828 }
829
830 let mut status_vec: Vec<(&str, usize, u8)> = status_counts
832 .iter()
833 .map(|(v, c)| (*v, *c, status_priority(v)))
834 .collect();
835 status_vec.sort_by(|a, b| a.2.cmp(&b.2).then(b.1.cmp(&a.1)).then(a.0.cmp(b.0)));
837
838 if let Some((top_status, count, _)) = status_vec.first() {
839 let remaining = MAX_HINTS.saturating_sub(hints.len());
840 if remaining > 0 {
841 hints.push(Hint::new(
842 format!("Filter by status: {top_status} ({count} files)"),
843 build_command_with_glob(
844 ctx,
845 &["find", "--property", &format!("status={top_status}")],
846 ),
847 ));
848 }
849 }
850
851 if ctx.sort.is_none() {
853 let remaining = MAX_HINTS.saturating_sub(hints.len());
854 if remaining > 0 {
855 hints.push(Hint::new(
856 "Sort by most recently modified",
857 build_find_command_preserving_filters(
858 ctx,
859 &["--sort", "modified", "--reverse"],
860 ),
861 ));
862 }
863 }
864
865 if !ctx.has_limit {
867 let remaining = MAX_HINTS.saturating_sub(hints.len());
868 if remaining > 0 {
869 hints.push(Hint::new(
870 "Limit to 10 results",
871 build_find_command_preserving_filters(ctx, &["--limit", "10"]),
872 ));
873 }
874 }
875 }
876
877 if let Some(view_hint) = suggest_save_as_view(ctx) {
879 let remaining = MAX_HINTS.saturating_sub(hints.len());
880 if remaining > 0 {
881 hints.push(view_hint);
882 }
883 }
884
885 if let Some(pat) = &ctx.body_pattern {
891 let has_quotes = pat.contains('"');
892 let words: Vec<&str> = pat
893 .split_whitespace()
894 .filter(|w| {
895 !w.starts_with('-')
896 && !w.eq_ignore_ascii_case("or")
897 && !w.eq_ignore_ascii_case("and")
898 })
899 .collect();
900 if !has_quotes && words.len() >= 2 && result_count > 10 {
901 let remaining = MAX_HINTS.saturating_sub(hints.len());
902 if remaining > 0 {
903 let phrase = format!("\"{}\"", words.join(" "));
904 hints.push(Hint::new(
905 "Try as exact phrase for more precise results",
906 build_find_command_with_pattern(ctx, &phrase),
907 ));
908 }
909 }
910 }
911
912 let has_broken_links = results.iter().any(|item| {
915 item.get("links")
916 .and_then(|l| l.as_array())
917 .is_some_and(|links| {
918 links
919 .iter()
920 .any(|link| link.get("path").is_some_and(serde_json::Value::is_null))
921 })
922 });
923 if has_broken_links {
924 let remaining = MAX_HINTS.saturating_sub(hints.len());
925 if remaining > 0 {
926 hints.push(Hint::new(
927 "Auto-fix broken links (dry run)",
928 build_command_with_glob(ctx, &["links", "fix"]),
929 ));
930 }
931 }
932
933 hints
934}
935
936fn hints_for_tags_summary(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
937 let Some(tags_arr) = data.as_array() else {
939 return vec![];
940 };
941
942 let mut entries: Vec<(&str, u64)> = tags_arr
944 .iter()
945 .filter_map(|entry| {
946 let name = entry.get("name").and_then(|n| n.as_str())?;
947 let count = entry
948 .get("count")
949 .and_then(serde_json::Value::as_u64)
950 .unwrap_or(0);
951 Some((name, count))
952 })
953 .collect();
954 entries.sort_by(|a, b| b.1.cmp(&a.1));
955
956 entries
957 .into_iter()
958 .take(3)
959 .map(|(name, count)| {
960 Hint::new(
961 format!("Find {count} files tagged: {name}"),
962 build_command_with_glob(ctx, &["find", "--tag", name]),
963 )
964 })
965 .collect()
966}
967
968fn hints_for_mutation(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
969 let mut hints = Vec::new();
970
971 let first_modified = first_modified_file(data);
972
973 if let Some(file) = first_modified {
974 hints.push(Hint::new(
975 "Verify the updated file",
976 build_command_no_glob(
977 ctx,
978 &["find", "--file", file, "--fields", "properties,tags"],
979 ),
980 ));
981 hints.push(Hint::new(
982 "Read the modified file",
983 build_command_no_glob(ctx, &["read", file]),
984 ));
985 }
986
987 hints
988}
989
990fn hints_for_read(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
991 let mut hints = Vec::new();
992
993 let file = data
994 .get("file")
995 .and_then(|f| f.as_str())
996 .or_else(|| ctx.file_targets.first().map(String::as_str));
997
998 if let Some(file) = file {
999 hints.push(Hint::new(
1000 "See metadata for this file",
1001 build_command_no_glob(ctx, &["find", "--file", file, "--fields", "all"]),
1002 ));
1003 hints.push(Hint::new(
1004 "See what links to this file",
1005 build_command_with_file(ctx, &["backlinks"], file, &[]),
1006 ));
1007 }
1008
1009 hints
1010}
1011
1012fn hints_for_backlinks(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1013 let mut hints = Vec::new();
1014
1015 let file = data.get("file").and_then(|f| f.as_str());
1016
1017 if let Some(file) = file {
1018 hints.push(Hint::new(
1019 "Read this file's content",
1020 build_command_with_file(ctx, &["read"], file, &[]),
1021 ));
1022 hints.push(Hint::new(
1023 "See this file's outgoing links",
1024 build_command_no_glob(ctx, &["find", "--file", file, "--fields", "links"]),
1025 ));
1026 }
1027
1028 if let Some(backlinks) = data.get("backlinks").and_then(|b| b.as_array())
1030 && let Some(first_source) = backlinks
1031 .first()
1032 .and_then(|b| b.get("source"))
1033 .and_then(|s| s.as_str())
1034 {
1035 hints.push(Hint::new(
1036 format!("Read linking file: {first_source}"),
1037 build_command_with_file(ctx, &["read"], first_source, &[]),
1038 ));
1039 }
1040
1041 hints
1042}
1043
1044fn hints_for_mv(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1045 let mut hints = Vec::new();
1046
1047 let to_path = data.get("to").and_then(|t| t.as_str());
1048 let is_dry_run = data
1049 .get("dry_run")
1050 .and_then(serde_json::Value::as_bool)
1051 .unwrap_or(false);
1052
1053 if let Some(to_path) = to_path {
1054 if is_dry_run {
1055 if let Some(from_path) = data.get("from").and_then(|f| f.as_str()) {
1056 hints.push(Hint::new(
1057 "Apply this move",
1058 build_command_with_file(ctx, &["mv"], from_path, &["--to", to_path]),
1059 ));
1060 }
1061 } else {
1062 hints.push(Hint::new(
1063 "Read the moved file",
1064 build_command_with_file(ctx, &["read"], to_path, &[]),
1065 ));
1066 hints.push(Hint::new(
1067 "Verify backlinks updated",
1068 build_command_with_file(ctx, &["backlinks"], to_path, &[]),
1069 ));
1070 }
1071 }
1072
1073 hints
1074}
1075
1076fn task_result_has_open(data: &serde_json::Value) -> bool {
1078 if let Some(arr) = data.as_array() {
1080 return arr
1081 .iter()
1082 .any(|t| t.get("done") == Some(&serde_json::Value::Bool(false)));
1083 }
1084 data.get("done") == Some(&serde_json::Value::Bool(false))
1086}
1087
1088fn hints_for_task_read(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1090 let mut hints = Vec::new();
1091
1092 if let Some(selector) = &ctx.task_selector {
1094 if let Some(file) = ctx.file_targets.first() {
1095 let has_open = task_result_has_open(data);
1096 if has_open {
1097 if selector == "all" {
1098 hints.push(Hint::new(
1099 "Toggle all tasks in this file",
1100 build_command_with_file(ctx, &["task", "toggle"], file, &["--all"]),
1101 ));
1102 } else if let Some(section) = selector.strip_prefix("section:") {
1103 hints.push(Hint::new(
1104 format!("Toggle all tasks in section \"{section}\""),
1105 build_command_with_file(
1106 ctx,
1107 &["task", "toggle"],
1108 file,
1109 &["--section", section],
1110 ),
1111 ));
1112 }
1113 }
1114 }
1115 if selector != "lines" {
1119 return hints;
1120 }
1121 }
1122
1123 let file = data.get("file").and_then(|f| f.as_str());
1125 let line = data.get("line").and_then(serde_json::Value::as_u64);
1126 let done = data
1127 .get("done")
1128 .and_then(serde_json::Value::as_bool)
1129 .unwrap_or(false);
1130
1131 if let (Some(file), Some(line)) = (file, line) {
1132 let line_str = line.to_string();
1133 if !done {
1134 hints.push(Hint::new(
1135 "Toggle this task to done",
1136 build_command_with_file(ctx, &["task", "toggle"], file, &["--line", &line_str]),
1137 ));
1138 }
1139 hints.push(Hint::new(
1140 "See all open tasks in this file",
1141 build_command_no_glob(
1142 ctx,
1143 &[
1144 "find", "--file", file, "--task", "todo", "--fields", "tasks",
1145 ],
1146 ),
1147 ));
1148 }
1149
1150 hints
1151}
1152
1153fn hints_for_task_mutation(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1154 let mut hints = Vec::new();
1155
1156 let file = ctx
1157 .file_targets
1158 .first()
1159 .map(String::as_str)
1160 .or_else(|| data.get("file").and_then(|f| f.as_str()));
1161
1162 if let Some(file) = file {
1163 if let Some(selector) = &ctx.task_selector {
1165 if selector == "all" {
1166 hints.push(Hint::new(
1167 "Read all tasks in this file",
1168 build_command_with_file(ctx, &["task", "read"], file, &["--all"]),
1169 ));
1170 } else if let Some(section) = selector.strip_prefix("section:") {
1171 hints.push(Hint::new(
1172 format!("Read tasks in section \"{section}\""),
1173 build_command_with_file(ctx, &["task", "read"], file, &["--section", section]),
1174 ));
1175 }
1176 }
1177
1178 hints.push(Hint::new(
1179 "See remaining open tasks",
1180 build_command_no_glob(
1181 ctx,
1182 &[
1183 "find", "--file", file, "--task", "todo", "--fields", "tasks",
1184 ],
1185 ),
1186 ));
1187 hints.push(Hint::new(
1188 "Read the file",
1189 build_command_with_file(ctx, &["read"], file, &[]),
1190 ));
1191 }
1192
1193 hints
1194}
1195
1196fn hints_for_links_fix(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1197 let mut hints = Vec::new();
1198
1199 let is_dry_run = !data
1200 .get("applied")
1201 .and_then(serde_json::Value::as_bool)
1202 .unwrap_or(false);
1203 let fixable = data
1204 .get("fixable")
1205 .and_then(serde_json::Value::as_u64)
1206 .unwrap_or(0);
1207 let unfixable = data
1208 .get("unfixable")
1209 .and_then(serde_json::Value::as_u64)
1210 .unwrap_or(0);
1211
1212 if is_dry_run && fixable > 0 {
1213 hints.push(Hint::new(
1214 format!("Apply {fixable} fixes"),
1215 build_command_with_glob(ctx, &["links", "fix", "--apply"]),
1216 ));
1217 }
1218
1219 if unfixable > 0 {
1220 hints.push(Hint::new(
1221 "List files with remaining broken links",
1222 build_command_with_glob(ctx, &["find", "--broken-links"]),
1223 ));
1224 }
1225
1226 hints
1227}
1228
1229fn hints_for_create_index(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1230 let mut hints = Vec::new();
1231
1232 let index_path = data
1235 .get("path")
1236 .and_then(|p| p.as_str())
1237 .or(ctx.index_path.as_deref());
1238
1239 let is_default = index_path.is_none_or(|p| p == ".hyalo-index");
1242
1243 let hint_cmd = if is_default {
1244 build_command_no_glob(ctx, &["find", "--index"])
1245 } else {
1246 build_command_no_glob(
1247 ctx,
1248 &["find", "--index", index_path.unwrap_or(".hyalo-index")],
1249 )
1250 };
1251
1252 hints.push(Hint::new("Query using the index", hint_cmd));
1253 hints.push(Hint::new(
1254 "Delete the index when done",
1255 build_command_no_glob(ctx, &["drop-index"]),
1256 ));
1257
1258 hints
1259}
1260
1261fn hints_for_drop_index(ctx: &HintContext, _data: &serde_json::Value) -> Vec<Hint> {
1262 vec![Hint::new(
1263 "Rebuild the index",
1264 build_command_no_glob(ctx, &["create-index"]),
1265 )]
1266}
1267
1268fn hints_for_lint(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1269 let mut hints = Vec::new();
1270
1271 let is_dry_run = data
1273 .get("dry_run")
1274 .and_then(serde_json::Value::as_bool)
1275 .unwrap_or(false);
1276 let has_fixes = data
1277 .get("fixes")
1278 .and_then(|f| f.as_array())
1279 .is_some_and(|a| !a.is_empty());
1280
1281 if is_dry_run && has_fixes && hints.len() < MAX_HINTS {
1282 hints.push(Hint::new(
1283 "Apply fixes (remove --dry-run)",
1284 build_command_with_glob_and_files(ctx, &["lint", "--fix"]),
1285 ));
1286 }
1287
1288 let has_violations = data
1290 .get("files")
1291 .and_then(|f| f.as_array())
1292 .is_some_and(|files| {
1293 files.iter().any(|file| {
1294 file.get("violations")
1295 .and_then(|v| v.as_array())
1296 .is_some_and(|v| !v.is_empty())
1297 })
1298 });
1299 if has_violations && !is_dry_run && hints.len() < MAX_HINTS {
1300 hints.push(Hint::new(
1301 "Preview auto-fixes",
1302 build_command_with_glob_and_files(ctx, &["lint", "--fix", "--dry-run"]),
1303 ));
1304 if hints.len() < MAX_HINTS {
1305 hints.push(Hint::new(
1306 "Apply auto-fixes",
1307 build_command_with_glob_and_files(ctx, &["lint", "--fix"]),
1308 ));
1309 }
1310 }
1311
1312 if hints.len() < MAX_HINTS {
1314 hints.push(Hint::new(
1315 "See defined type schemas",
1316 build_command_no_glob(ctx, &["types", "list"]),
1317 ));
1318 }
1319
1320 hints
1321}
1322
1323fn hints_for_types(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1324 let subcommand = match &ctx.source {
1325 HintSource::Types { subcommand } => subcommand.as_deref().unwrap_or("list"),
1326 _ => "list",
1327 };
1328
1329 let mut hints = Vec::new();
1330
1331 match subcommand {
1332 "list" => {
1333 if let Some(first_type) = data
1335 .as_array()
1336 .and_then(|arr| arr.first())
1337 .and_then(|entry| entry.get("type"))
1338 .and_then(serde_json::Value::as_str)
1339 {
1340 hints.push(Hint::new(
1341 format!("Show schema for type: {first_type}"),
1342 build_command_no_glob(ctx, &["types", "show", first_type]),
1343 ));
1344 }
1345 if hints.len() < MAX_HINTS {
1346 hints.push(Hint::new(
1347 "Validate all files against schema",
1348 build_command_no_glob(ctx, &["lint"]),
1349 ));
1350 }
1351 }
1352 "show" => {
1353 let type_name = data.get("type").and_then(serde_json::Value::as_str);
1354 if hints.len() < MAX_HINTS {
1355 hints.push(Hint::new(
1356 "Validate files against schema",
1357 build_command_no_glob(ctx, &["lint"]),
1358 ));
1359 }
1360 if hints.len() < MAX_HINTS {
1361 hints.push(Hint::new(
1362 "List all type schemas",
1363 build_command_no_glob(ctx, &["types", "list"]),
1364 ));
1365 }
1366 if let Some(name) = type_name
1367 && hints.len() < MAX_HINTS
1368 {
1369 let filter = format!("type={name}");
1370 hints.push(Hint::new(
1371 format!("Find files of type: {name}"),
1372 build_command_no_glob(ctx, &["find", "--property", &filter]),
1373 ));
1374 }
1375 }
1376 "create" => {
1377 let type_name = data.get("type").and_then(serde_json::Value::as_str);
1378 if let Some(name) = type_name
1379 && hints.len() < MAX_HINTS
1380 {
1381 hints.push(Hint::new(
1382 format!("Show the new type schema: {name}"),
1383 build_command_no_glob(ctx, &["types", "show", name]),
1384 ));
1385 }
1386 if hints.len() < MAX_HINTS {
1387 hints.push(Hint::new(
1388 "Validate files against schema",
1389 build_command_no_glob(ctx, &["lint"]),
1390 ));
1391 }
1392 }
1393 "remove" => {
1394 if hints.len() < MAX_HINTS {
1395 hints.push(Hint::new(
1396 "List remaining type schemas",
1397 build_command_no_glob(ctx, &["types", "list"]),
1398 ));
1399 }
1400 }
1401 "set" => {
1402 let type_name = data.get("type").and_then(serde_json::Value::as_str);
1403 if let Some(name) = type_name
1404 && hints.len() < MAX_HINTS
1405 {
1406 hints.push(Hint::new(
1407 format!("Review updated schema: {name}"),
1408 build_command_no_glob(ctx, &["types", "show", name]),
1409 ));
1410 }
1411 if hints.len() < MAX_HINTS {
1412 hints.push(Hint::new(
1413 "Validate files against schema",
1414 build_command_no_glob(ctx, &["lint"]),
1415 ));
1416 }
1417 }
1418 _ => {}
1419 }
1420
1421 hints
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426 use super::*;
1427 use serde_json::json;
1428
1429 fn ctx(source: HintSource) -> HintContext {
1430 HintContext::new(source)
1431 }
1432
1433 fn ctx_with_dir(source: HintSource, dir: &str) -> HintContext {
1434 let mut ctx = HintContext::new(source);
1435 ctx.dir = Some(dir.to_owned());
1436 ctx
1437 }
1438
1439 fn ctx_with_glob(source: HintSource, glob: &str) -> HintContext {
1440 let mut ctx = HintContext::new(source);
1441 ctx.glob = vec![glob.to_owned()];
1442 ctx
1443 }
1444
1445 #[test]
1448 fn shell_quote_plain_string() {
1449 assert_eq!(shell_quote("status"), "status");
1450 }
1451
1452 #[test]
1453 fn shell_quote_string_with_space() {
1454 assert_eq!(shell_quote("in progress"), "'in progress'");
1455 }
1456
1457 #[test]
1458 fn shell_quote_string_with_special_chars() {
1459 assert_eq!(shell_quote("foo$bar"), "'foo$bar'");
1460 }
1461
1462 #[test]
1463 fn shell_quote_string_with_single_quote() {
1464 assert_eq!(shell_quote("it's"), "'it'\\''s'");
1465 }
1466
1467 #[test]
1468 fn shell_quote_glob_chars() {
1469 assert_eq!(shell_quote("**/*.md"), "'**/*.md'");
1470 }
1471
1472 #[test]
1473 fn shell_quote_empty_string() {
1474 assert_eq!(shell_quote(""), "''");
1475 }
1476
1477 #[test]
1480 fn build_command_no_flags() {
1481 let c = ctx(HintSource::Summary);
1482 assert_eq!(
1483 build_command_no_glob(&c, &["properties"]),
1484 "hyalo properties"
1485 );
1486 }
1487
1488 #[test]
1489 fn build_command_with_dir() {
1490 let c = ctx_with_dir(HintSource::Summary, "/my/vault");
1491 assert_eq!(
1492 build_command_no_glob(&c, &["tags"]),
1493 "hyalo tags --dir /my/vault"
1494 );
1495 }
1496
1497 #[test]
1498 fn build_command_with_glob_propagated() {
1499 let c = ctx_with_glob(HintSource::PropertiesSummary, "**/*.md");
1500 assert_eq!(
1501 build_command_with_glob(&c, &["properties"]),
1502 "hyalo properties --glob '**/*.md'"
1503 );
1504 }
1505
1506 #[test]
1509 fn status_priority_ordering() {
1510 assert!(status_priority("in-progress") < status_priority("planned"));
1511 assert!(status_priority("planned") < status_priority("draft"));
1512 assert!(status_priority("draft") < status_priority("custom"));
1513 assert!(status_priority("custom") < status_priority("completed"));
1514 }
1515
1516 #[test]
1519 fn summary_always_includes_properties_and_tags() {
1520 let c = ctx(HintSource::Summary);
1521 let data = json!({
1522 "files": {"total": 10, "by_directory": []},
1523 "properties": [],
1524 "tags": {"tags": [], "total": 0},
1525 "status": [],
1526 "tasks": {"total": 0, "done": 0},
1527 "recent_files": []
1528 });
1529 let hints = generate_hints(&c, &data);
1530 assert!(hints.iter().any(|h| {
1531 h.cmd == "hyalo properties"
1532 || (h.cmd.starts_with("hyalo properties ") && h.cmd.contains("--dir "))
1533 || (h.cmd.starts_with("hyalo properties ") && h.cmd.contains("--glob "))
1534 }));
1535 assert!(hints.iter().any(|h| {
1536 h.cmd == "hyalo tags"
1537 || (h.cmd.starts_with("hyalo tags ") && h.cmd.contains("--dir "))
1538 || (h.cmd.starts_with("hyalo tags ") && h.cmd.contains("--glob "))
1539 }));
1540 }
1541
1542 #[test]
1543 fn summary_suggests_tasks_todo_when_open_tasks() {
1544 let c = ctx(HintSource::Summary);
1545 let data = json!({
1546 "files": {"total": 5, "by_directory": []},
1547 "properties": [],
1548 "tags": {"tags": [], "total": 0},
1549 "status": [],
1550 "tasks": {"total": 10, "done": 3},
1551 "recent_files": []
1552 });
1553 let hints = generate_hints(&c, &data);
1554 assert!(
1555 hints.iter().any(|h| h.cmd.contains("find")
1556 && h.cmd.contains("--task")
1557 && h.cmd.contains("todo"))
1558 );
1559 }
1560
1561 #[test]
1562 fn summary_omits_tasks_todo_when_all_done() {
1563 let c = ctx(HintSource::Summary);
1564 let data = json!({
1565 "files": {"total": 5, "by_directory": []},
1566 "properties": [],
1567 "tags": {"tags": [], "total": 0},
1568 "status": [],
1569 "tasks": {"total": 10, "done": 10},
1570 "recent_files": []
1571 });
1572 let hints = generate_hints(&c, &data);
1573 assert!(!hints.iter().any(|h| h.cmd.contains("--todo")));
1574 }
1575
1576 #[test]
1577 fn summary_picks_interesting_status_values() {
1578 let c = ctx(HintSource::Summary);
1579 let data = json!({
1580 "files": {"total": 5, "by_directory": []},
1581 "properties": [],
1582 "tags": {"tags": [], "total": 0},
1583 "status": [
1584 {"value": "completed", "files": ["a.md"]},
1585 {"value": "in-progress", "files": ["b.md"]},
1586 {"value": "planned", "files": ["c.md"]}
1587 ],
1588 "tasks": {"total": 0, "done": 0},
1589 "recent_files": []
1590 });
1591 let hints = generate_hints(&c, &data);
1592 let in_progress_pos = hints.iter().position(|h| h.cmd.contains("in-progress"));
1594 let completed_pos = hints.iter().position(|h| h.cmd.contains("completed"));
1595 assert!(in_progress_pos.is_some(), "should suggest in-progress");
1596 if let Some(cp) = completed_pos {
1598 assert!(in_progress_pos.unwrap() < cp);
1599 }
1600 }
1601
1602 #[test]
1603 fn summary_max_hints_not_exceeded() {
1604 let c = ctx(HintSource::Summary);
1605 let data = json!({
1606 "files": {"total": 5, "by_directory": []},
1607 "properties": [],
1608 "tags": {"tags": [], "total": 0},
1609 "status": [
1610 {"value": "in-progress", "files": ["a.md"]},
1611 {"value": "planned", "files": ["b.md"]},
1612 {"value": "draft", "files": ["c.md"]},
1613 {"value": "idea", "files": ["d.md"]}
1614 ],
1615 "tasks": {"total": 5, "done": 1},
1616 "recent_files": []
1617 });
1618 let hints = generate_hints(&c, &data);
1619 assert!(hints.len() <= MAX_HINTS);
1620 }
1621
1622 #[test]
1625 fn properties_summary_top3_by_count() {
1626 let c = ctx(HintSource::PropertiesSummary);
1627 let data = json!([
1628 {"name": "title", "type": "text", "count": 100},
1629 {"name": "status", "type": "text", "count": 50},
1630 {"name": "tags", "type": "list", "count": 30},
1631 {"name": "author", "type": "text", "count": 5}
1632 ]);
1633 let hints = generate_hints(&c, &data);
1634 assert_eq!(hints.len(), 3);
1635 assert!(hints[0].cmd.contains("title"));
1636 assert!(hints[1].cmd.contains("status"));
1637 assert!(hints[2].cmd.contains("tags"));
1638 assert!(!hints.iter().any(|h| h.cmd.contains("author")));
1640 }
1641
1642 #[test]
1643 fn properties_summary_empty_data() {
1644 let c = ctx(HintSource::PropertiesSummary);
1645 let hints = generate_hints(&c, &json!([]));
1646 assert!(hints.is_empty());
1647 }
1648
1649 #[test]
1650 fn properties_summary_propagates_glob() {
1651 let c = ctx_with_glob(HintSource::PropertiesSummary, "notes/*.md");
1652 let data = json!([{"name": "status", "type": "text", "count": 5}]);
1653 let hints = generate_hints(&c, &data);
1654 assert!(hints[0].cmd.contains("--glob"));
1655 assert!(hints[0].cmd.contains("notes/*.md"));
1656 }
1657
1658 fn make_find_item(file: &str, status: Option<&str>, tags: &[&str]) -> serde_json::Value {
1661 let mut props = serde_json::Map::new();
1662 if let Some(s) = status {
1663 props.insert("status".to_owned(), serde_json::Value::String(s.to_owned()));
1664 }
1665 json!({
1666 "file": file,
1667 "properties": props,
1668 "tags": tags,
1669 "sections": [],
1670 "tasks": [],
1671 "links": [],
1672 "modified": "2026-01-01T00:00:00Z"
1673 })
1674 }
1675
1676 #[test]
1677 fn find_empty_results_no_hints() {
1678 let c = ctx(HintSource::Find);
1679 let hints = generate_hints(&c, &json!([]));
1680 assert!(hints.is_empty());
1681 }
1682
1683 #[test]
1684 fn find_single_result_suggests_read_and_backlinks() {
1685 let c = ctx(HintSource::Find);
1686 let items = vec![make_find_item("notes/alpha.md", None, &[])];
1687 let data = json!(items);
1688 let hints = generate_hints(&c, &data);
1689 assert!(
1690 hints
1691 .iter()
1692 .any(|h| h.cmd.contains("read") && h.cmd.contains("alpha.md")),
1693 "should suggest read: {hints:?}"
1694 );
1695 assert!(
1696 hints
1697 .iter()
1698 .any(|h| h.cmd.contains("backlinks") && h.cmd.contains("alpha.md")),
1699 "should suggest backlinks: {hints:?}"
1700 );
1701 }
1702
1703 #[test]
1704 fn find_many_results_suggests_top_tag() {
1705 let c = ctx(HintSource::Find);
1706 let items = vec![
1708 make_find_item("a.md", Some("planned"), &["rust", "cli"]),
1709 make_find_item("b.md", Some("planned"), &["rust"]),
1710 make_find_item("c.md", Some("in-progress"), &["rust"]),
1711 make_find_item("d.md", Some("completed"), &["rust"]),
1712 make_find_item("e.md", Some("completed"), &["cli"]),
1713 make_find_item("f.md", Some("completed"), &[]),
1714 ];
1715 let data = json!(items);
1716 let hints = generate_hints(&c, &data);
1717 assert!(
1718 hints
1719 .iter()
1720 .any(|h| h.cmd.contains("--tag") && h.cmd.contains("rust")),
1721 "should suggest --tag rust (most common): {hints:?}"
1722 );
1723 }
1724
1725 #[test]
1726 fn find_many_results_suggests_interesting_status() {
1727 let c = ctx(HintSource::Find);
1728 let items = vec![
1730 make_find_item("a.md", Some("in-progress"), &[]),
1731 make_find_item("b.md", Some("completed"), &[]),
1732 make_find_item("c.md", Some("completed"), &[]),
1733 make_find_item("d.md", Some("completed"), &[]),
1734 make_find_item("e.md", Some("completed"), &[]),
1735 make_find_item("f.md", Some("completed"), &[]),
1736 ];
1737 let data = json!(items);
1738 let hints = generate_hints(&c, &data);
1739 assert!(
1740 hints
1741 .iter()
1742 .any(|h| h.cmd.contains("--property") && h.cmd.contains("status=in-progress")),
1743 "should prefer in-progress status: {hints:?}"
1744 );
1745 }
1746
1747 #[test]
1748 fn find_many_results_no_tags_falls_back_to_status() {
1749 let c = ctx(HintSource::Find);
1750 let items = vec![
1752 make_find_item("a.md", Some("planned"), &[]),
1753 make_find_item("b.md", Some("planned"), &[]),
1754 make_find_item("c.md", Some("planned"), &[]),
1755 make_find_item("d.md", Some("planned"), &[]),
1756 make_find_item("e.md", Some("planned"), &[]),
1757 make_find_item("f.md", Some("planned"), &[]),
1758 ];
1759 let data = json!(items);
1760 let hints = generate_hints(&c, &data);
1761 assert!(
1762 hints
1763 .iter()
1764 .any(|h| h.cmd.contains("--property") && h.cmd.contains("status=planned")),
1765 "should suggest status filter: {hints:?}"
1766 );
1767 assert!(
1769 !hints.iter().any(|h| h.cmd.contains("--tag")),
1770 "should not suggest --tag when no tags: {hints:?}"
1771 );
1772 }
1773
1774 #[test]
1775 fn find_hints_never_exceed_max() {
1776 let c = ctx(HintSource::Find);
1777 let items: Vec<serde_json::Value> = (0..10)
1779 .map(|i| make_find_item(&format!("{i}.md"), Some("planned"), &["rust", "cli"]))
1780 .collect();
1781 let data = json!(items);
1782 let hints = generate_hints(&c, &data);
1783 assert!(hints.len() <= MAX_HINTS);
1784 }
1785
1786 #[test]
1787 fn find_sort_hint_preserves_existing_filters() {
1788 let mut c = ctx(HintSource::Find);
1789 c.property_filters = vec!["status=draft".to_owned()];
1790 c.tag_filters = vec!["research".to_owned()];
1791 let items: Vec<serde_json::Value> = (0..6)
1793 .map(|i| make_find_item(&format!("{i}.md"), Some("draft"), &["research"]))
1794 .collect();
1795 let data = json!(items);
1796 let hints = generate_hints(&c, &data);
1797 let sort_hint = hints.iter().find(|h| h.cmd.contains("--sort"));
1798 assert!(sort_hint.is_some(), "should include a sort hint: {hints:?}");
1799 let cmd = &sort_hint.unwrap().cmd;
1800 assert!(
1801 cmd.contains("--property status=draft"),
1802 "sort hint should preserve --property filter: {cmd}"
1803 );
1804 assert!(
1805 cmd.contains("--tag research"),
1806 "sort hint should preserve --tag filter: {cmd}"
1807 );
1808 }
1809
1810 #[test]
1811 fn find_limit_hint_preserves_existing_filters() {
1812 let mut c = ctx(HintSource::Find);
1813 c.tag_filters = vec!["iteration".to_owned()];
1814 let items: Vec<serde_json::Value> = (0..6)
1815 .map(|i| make_find_item(&format!("{i}.md"), Some("planned"), &["iteration"]))
1816 .collect();
1817 let data = json!(items);
1818 let hints = generate_hints(&c, &data);
1819 let limit_hint = hints.iter().find(|h| h.cmd.contains("--limit"));
1820 assert!(
1821 limit_hint.is_some(),
1822 "should include a limit hint: {hints:?}"
1823 );
1824 let cmd = &limit_hint.unwrap().cmd;
1825 assert!(
1826 cmd.contains("--tag iteration"),
1827 "limit hint should preserve --tag filter: {cmd}"
1828 );
1829 }
1830
1831 #[test]
1834 fn dir_flag_propagated_to_all_hints() {
1835 let c = ctx_with_dir(HintSource::TagsSummary, "/vault");
1836 let data = json!([{"name": "rust", "count": 5}]);
1838 let hints = generate_hints(&c, &data);
1839 assert!(hints[0].cmd.contains("--dir"));
1840 assert!(hints[0].cmd.contains("/vault"));
1841 }
1842
1843 #[test]
1846 fn mutation_hints_suggest_verify_and_read() {
1847 let c = ctx(HintSource::Set);
1848 let data = json!({
1849 "property": "status",
1850 "value": "completed",
1851 "modified": ["notes/alpha.md"],
1852 "skipped": [],
1853 "total": 1
1854 });
1855 let hints = generate_hints(&c, &data);
1856 assert!(
1857 hints
1858 .iter()
1859 .any(|h| h.cmd.contains("find") && h.cmd.contains("alpha.md")),
1860 "should suggest verify: {hints:?}"
1861 );
1862 assert!(
1863 hints
1864 .iter()
1865 .any(|h| h.cmd.contains("read") && h.cmd.contains("alpha.md")),
1866 "should suggest read: {hints:?}"
1867 );
1868 }
1869
1870 #[test]
1871 fn read_hints_suggest_metadata_and_backlinks() {
1872 let c = ctx(HintSource::Read);
1873 let data = json!({"file": "notes/alpha.md", "content": "Some content"});
1874 let hints = generate_hints(&c, &data);
1875 assert!(
1876 hints
1877 .iter()
1878 .any(|h| h.cmd.contains("find") && h.cmd.contains("alpha.md")),
1879 "should suggest find: {hints:?}"
1880 );
1881 assert!(
1882 hints
1883 .iter()
1884 .any(|h| h.cmd.contains("backlinks") && h.cmd.contains("alpha.md")),
1885 "should suggest backlinks: {hints:?}"
1886 );
1887 }
1888
1889 #[test]
1890 fn backlinks_hints_suggest_read_and_outgoing() {
1891 let c = ctx(HintSource::Backlinks);
1892 let data = json!({
1893 "file": "target.md",
1894 "backlinks": [{"source": "a.md", "line": 5, "target": "target"}],
1895 "total": 1
1896 });
1897 let hints = generate_hints(&c, &data);
1898 assert!(
1899 hints
1900 .iter()
1901 .any(|h| h.cmd.contains("read") && h.cmd.contains("target.md")),
1902 "should suggest read target: {hints:?}"
1903 );
1904 assert!(
1905 hints
1906 .iter()
1907 .any(|h| h.cmd.contains("read") && h.cmd.contains("a.md")),
1908 "should suggest read first backlink source: {hints:?}"
1909 );
1910 }
1911
1912 #[test]
1913 fn create_index_hints_suggest_find_and_drop() {
1914 let c = ctx(HintSource::CreateIndex);
1915 let data = json!({"path": ".hyalo-index", "files_indexed": 42, "warnings": 0});
1916 let hints = generate_hints(&c, &data);
1917 assert!(
1918 hints
1919 .iter()
1920 .any(|h| h.cmd.contains("find") && h.cmd.contains("--index")),
1921 "should suggest find with index: {hints:?}"
1922 );
1923 assert!(
1924 hints.iter().any(|h| h.cmd.contains("drop-index")),
1925 "should suggest drop-index: {hints:?}"
1926 );
1927 }
1928
1929 #[test]
1930 fn drop_index_hints_suggest_create() {
1931 let c = ctx(HintSource::DropIndex);
1932 let data = json!({"deleted": ".hyalo-index"});
1933 let hints = generate_hints(&c, &data);
1934 assert!(
1935 hints.iter().any(|h| h.cmd.contains("create-index")),
1936 "should suggest create-index: {hints:?}"
1937 );
1938 }
1939
1940 #[test]
1941 fn mv_dry_run_hints_suggest_apply() {
1942 let c = ctx(HintSource::Mv);
1943 let data = json!({
1944 "from": "old.md",
1945 "to": "new.md",
1946 "dry_run": true,
1947 "updated_files": [],
1948 "total_files_updated": 0,
1949 "total_links_updated": 0
1950 });
1951 let hints = generate_hints(&c, &data);
1952 assert!(
1953 hints.iter().any(|h| h.cmd.contains("mv")
1954 && h.cmd.contains("new.md")
1955 && !h.cmd.contains("dry-run")),
1956 "should suggest applying the move: {hints:?}"
1957 );
1958 }
1959
1960 #[test]
1961 fn mv_applied_hints_suggest_read_and_backlinks() {
1962 let c = ctx(HintSource::Mv);
1963 let data = json!({
1964 "from": "old.md",
1965 "to": "new.md",
1966 "dry_run": false,
1967 "updated_files": [],
1968 "total_files_updated": 0,
1969 "total_links_updated": 0
1970 });
1971 let hints = generate_hints(&c, &data);
1972 assert!(
1973 hints
1974 .iter()
1975 .any(|h| h.cmd.contains("read") && h.cmd.contains("new.md")),
1976 "should suggest reading moved file: {hints:?}"
1977 );
1978 assert!(
1979 hints
1980 .iter()
1981 .any(|h| h.cmd.contains("backlinks") && h.cmd.contains("new.md")),
1982 "should suggest checking backlinks: {hints:?}"
1983 );
1984 }
1985
1986 #[test]
1987 fn task_read_undone_suggests_toggle() {
1988 let c = ctx(HintSource::TaskRead);
1989 let data =
1990 json!({"file": "todo.md", "line": 5, "status": " ", "text": "Fix bug", "done": false});
1991 let hints = generate_hints(&c, &data);
1992 assert!(
1993 hints.iter().any(|h| h.cmd.contains("task toggle")),
1994 "should suggest toggling undone task: {hints:?}"
1995 );
1996 }
1997
1998 #[test]
1999 fn task_read_done_omits_toggle() {
2000 let c = ctx(HintSource::TaskRead);
2001 let data =
2002 json!({"file": "todo.md", "line": 5, "status": "x", "text": "Fix bug", "done": true});
2003 let hints = generate_hints(&c, &data);
2004 assert!(
2005 !hints.iter().any(|h| h.cmd.contains("task toggle")),
2006 "should not suggest toggling already-done task: {hints:?}"
2007 );
2008 assert!(
2009 hints.iter().any(|h| h.cmd.contains("--task todo")),
2010 "should suggest viewing open tasks: {hints:?}"
2011 );
2012 }
2013
2014 #[test]
2015 fn task_mutation_hints_suggest_remaining_tasks() {
2016 let c = ctx(HintSource::TaskToggle);
2017 let data =
2018 json!({"file": "todo.md", "line": 5, "status": "x", "text": "Fix bug", "done": true});
2019 let hints = generate_hints(&c, &data);
2020 assert!(
2021 hints.iter().any(|h| h.cmd.contains("find")
2022 && h.cmd.contains("--task")
2023 && h.cmd.contains("todo")),
2024 "should suggest finding remaining tasks: {hints:?}"
2025 );
2026 }
2027
2028 #[test]
2029 fn links_fix_dry_run_hints_suggest_apply() {
2030 let c = ctx(HintSource::LinksFix);
2031 let data = json!({
2032 "broken": 5,
2033 "fixable": 3,
2034 "unfixable": 2,
2035 "applied": false,
2036 "fixes": []
2037 });
2038 let hints = generate_hints(&c, &data);
2039 assert!(
2040 hints.iter().any(|h| h.cmd.contains("links fix --apply")),
2041 "should suggest applying fixes: {hints:?}"
2042 );
2043 assert!(
2044 hints.iter().any(|h| h.cmd.contains("--broken-links")),
2045 "should suggest finding broken links: {hints:?}"
2046 );
2047 }
2048
2049 #[test]
2050 fn find_broad_query_suggests_summary() {
2051 let c = ctx(HintSource::Find);
2052 let items: Vec<serde_json::Value> = (0..15)
2054 .map(|i| make_find_item(&format!("{i}.md"), Some("completed"), &[]))
2055 .collect();
2056 let data = json!(items);
2057 let hints = generate_hints(&c, &data);
2058 assert!(
2059 hints.iter().any(|h| h.cmd.contains("summary")),
2060 "broad query should suggest summary: {hints:?}"
2061 );
2062 }
2063
2064 #[test]
2065 fn find_with_filters_does_not_suggest_summary() {
2066 let mut c = ctx(HintSource::Find);
2067 c.tag_filters = vec!["rust".to_owned()];
2068 let items: Vec<serde_json::Value> = (0..15)
2069 .map(|i| make_find_item(&format!("{i}.md"), Some("completed"), &["rust"]))
2070 .collect();
2071 let data = json!(items);
2072 let hints = generate_hints(&c, &data);
2073 assert!(
2074 !hints.iter().any(|h| h.cmd.contains("summary")),
2075 "filtered query should not suggest summary: {hints:?}"
2076 );
2077 }
2078
2079 #[test]
2080 fn find_suppresses_already_filtered_tag() {
2081 let mut c = ctx(HintSource::Find);
2082 c.tag_filters = vec!["rust".to_owned()];
2083 let items: Vec<serde_json::Value> = (0..10)
2084 .map(|i| make_find_item(&format!("{i}.md"), Some("planned"), &["rust", "cli"]))
2085 .collect();
2086 let data = json!(items);
2087 let hints = generate_hints(&c, &data);
2088 assert!(
2092 !hints
2093 .iter()
2094 .any(|h| h.description.starts_with("Narrow") && h.cmd.contains("--tag rust")),
2095 "should not suggest narrowing by already-filtered tag: {hints:?}"
2096 );
2097 assert!(
2098 hints.iter().any(|h| h.cmd.contains("--tag cli")),
2099 "should suggest non-filtered tag: {hints:?}"
2100 );
2101 }
2102
2103 #[test]
2104 fn summary_broken_links_suggests_links_fix() {
2105 let c = ctx(HintSource::Summary);
2106 let data = json!({
2107 "files": 10,
2108 "links": {"total": 20, "broken": 3},
2109 "properties": [],
2110 "tags": [],
2111 "status": [],
2112 "tasks": {"total": 0, "done": 0},
2113 "orphans": 0
2114 });
2115 let hints = generate_hints(&c, &data);
2116 assert!(
2117 hints.iter().any(|h| h.cmd.contains("links fix")),
2118 "summary with broken links should suggest links fix: {hints:?}"
2119 );
2120 assert!(
2121 hints.iter().any(|h| h.cmd.contains("--broken-links")),
2122 "summary with broken links should also suggest find --broken-links: {hints:?}"
2123 );
2124 }
2125
2126 #[test]
2127 fn summary_no_broken_links_omits_links_fix() {
2128 let c = ctx(HintSource::Summary);
2129 let data = json!({
2130 "files": 10,
2131 "links": {"total": 20, "broken": 0},
2132 "properties": [],
2133 "tags": [],
2134 "status": [],
2135 "tasks": {"total": 0, "done": 0},
2136 "orphans": 0
2137 });
2138 let hints = generate_hints(&c, &data);
2139 assert!(
2140 !hints.iter().any(|h| h.cmd.contains("links fix")),
2141 "summary without broken links should not suggest links fix: {hints:?}"
2142 );
2143 }
2144
2145 #[test]
2146 fn find_with_broken_links_suggests_links_fix() {
2147 let c = ctx(HintSource::Find);
2148 let item = json!({
2149 "file": "doc.md",
2150 "properties": {},
2151 "tags": [],
2152 "sections": [],
2153 "tasks": [],
2154 "links": [
2155 {"target": "existing.md", "path": "existing.md", "kind": "wiki"},
2156 {"target": "gone.md", "path": null, "kind": "wiki"}
2157 ],
2158 "modified": "2026-01-01T00:00:00Z"
2159 });
2160 let data = json!([item]);
2161 let hints = generate_hints(&c, &data);
2162 assert!(
2163 hints.iter().any(|h| h.cmd.contains("links fix")),
2164 "find results with broken links should suggest links fix: {hints:?}"
2165 );
2166 }
2167
2168 #[test]
2169 fn find_without_broken_links_omits_links_fix() {
2170 let c = ctx(HintSource::Find);
2171 let item = json!({
2172 "file": "doc.md",
2173 "properties": {},
2174 "tags": [],
2175 "sections": [],
2176 "tasks": [],
2177 "links": [
2178 {"target": "existing.md", "path": "existing.md", "kind": "wiki"}
2179 ],
2180 "modified": "2026-01-01T00:00:00Z"
2181 });
2182 let data = json!([item]);
2183 let hints = generate_hints(&c, &data);
2184 assert!(
2185 !hints.iter().any(|h| h.cmd.contains("links fix")),
2186 "find results without broken links should not suggest links fix: {hints:?}"
2187 );
2188 }
2189
2190 #[test]
2193 fn lint_hints_suggest_fix_when_violations() {
2194 let c = ctx(HintSource::Lint);
2195 let data = json!({
2196 "files": [{"file": "test.md", "violations": [{"severity": "error", "message": "missing required property"}]}],
2197 "total": 1,
2198 });
2199 let hints = generate_hints(&c, &data);
2200 assert!(!hints.is_empty());
2201 assert!(
2202 hints.iter().any(|h| h.cmd.contains("lint --fix")),
2203 "should suggest lint --fix: {hints:?}"
2204 );
2205 }
2206
2207 #[test]
2208 fn lint_hints_suggest_apply_when_dry_run() {
2209 let mut c = ctx(HintSource::Lint);
2210 c.dry_run = true;
2211 let data = json!({
2212 "files": [],
2213 "total": 0,
2214 "fixes": [{"file": "test.md", "actions": [{"kind": "insert-default", "property": "status", "new": "draft"}]}],
2215 "dry_run": true,
2216 });
2217 let hints = generate_hints(&c, &data);
2218 assert!(
2219 hints
2220 .iter()
2221 .any(|h| h.cmd.contains("lint --fix") && !h.cmd.contains("--dry-run")),
2222 "dry-run mode should suggest lint --fix without --dry-run: {hints:?}"
2223 );
2224 }
2225
2226 #[test]
2227 fn lint_hints_always_suggest_types_list() {
2228 let c = ctx(HintSource::Lint);
2229 let data = json!({"files": [], "total": 0});
2230 let hints = generate_hints(&c, &data);
2231 assert!(
2232 hints.iter().any(|h| h.cmd.contains("types list")),
2233 "should always suggest types list: {hints:?}"
2234 );
2235 }
2236
2237 #[test]
2238 fn lint_hints_never_exceed_max() {
2239 let c = ctx(HintSource::Lint);
2240 let data = json!({
2241 "files": [{"file": "test.md", "violations": [{"severity": "error", "message": "x", "type": "iteration"}]}],
2242 "total": 5,
2243 });
2244 let hints = generate_hints(&c, &data);
2245 assert!(hints.len() <= MAX_HINTS);
2246 }
2247
2248 #[test]
2251 fn types_list_hints_suggest_show() {
2252 let c = ctx(HintSource::Types {
2253 subcommand: Some("list".to_owned()),
2254 });
2255 let data = json!([
2256 {"type": "iteration", "required": ["title"], "has_filename_template": true, "property_count": 3},
2257 {"type": "note", "required": [], "has_filename_template": false, "property_count": 1},
2258 ]);
2259 let hints = generate_hints(&c, &data);
2260 assert!(
2261 hints.iter().any(|h| h.cmd.contains("types show")),
2262 "should suggest types show: {hints:?}"
2263 );
2264 assert!(
2265 hints.iter().any(|h| h.cmd.contains("lint")),
2266 "should suggest lint: {hints:?}"
2267 );
2268 }
2269
2270 #[test]
2271 fn types_show_hints_suggest_lint_and_find() {
2272 let c = ctx(HintSource::Types {
2273 subcommand: Some("show".to_owned()),
2274 });
2275 let data = json!({"type": "iteration", "required": ["title"], "properties": {}});
2276 let hints = generate_hints(&c, &data);
2277 assert!(
2278 hints.iter().any(|h| h.cmd.contains("lint")),
2279 "should suggest lint: {hints:?}"
2280 );
2281 assert!(
2282 hints.iter().any(|h| h.cmd.contains("find --property")),
2283 "should suggest find --property: {hints:?}"
2284 );
2285 }
2286
2287 #[test]
2288 fn types_create_hints_suggest_show_and_lint() {
2289 let c = ctx(HintSource::Types {
2290 subcommand: Some("create".to_owned()),
2291 });
2292 let data = json!({"type": "note", "action": "created"});
2293 let hints = generate_hints(&c, &data);
2294 assert!(
2295 hints.iter().any(|h| h.cmd.contains("types show note")),
2296 "should suggest types show for created type: {hints:?}"
2297 );
2298 assert!(
2299 hints.iter().any(|h| h.cmd.contains("lint")),
2300 "should suggest lint: {hints:?}"
2301 );
2302 }
2303
2304 #[test]
2305 fn types_remove_hints_suggest_list() {
2306 let c = ctx(HintSource::Types {
2307 subcommand: Some("remove".to_owned()),
2308 });
2309 let data = json!({"type": "obsolete", "action": "removed"});
2310 let hints = generate_hints(&c, &data);
2311 assert!(
2312 hints.iter().any(|h| h.cmd.contains("types list")),
2313 "should suggest types list after remove: {hints:?}"
2314 );
2315 }
2316
2317 #[test]
2318 fn types_set_hints_suggest_show_and_lint() {
2319 let c = ctx(HintSource::Types {
2320 subcommand: Some("set".to_owned()),
2321 });
2322 let data = json!({"type": "iteration", "action": "updated"});
2323 let hints = generate_hints(&c, &data);
2324 assert!(
2325 hints.iter().any(|h| h.cmd.contains("types show iteration")),
2326 "should suggest types show for updated type: {hints:?}"
2327 );
2328 assert!(
2329 hints.iter().any(|h| h.cmd.contains("lint")),
2330 "should suggest lint: {hints:?}"
2331 );
2332 }
2333}