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