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