Skip to main content

hyalo_cli/
hints.rs

1//! Generates drill-down command hints for CLI output.
2//!
3//! When `--hints` is enabled, each command's output includes suggested next
4//! commands. All hints are concrete, executable strings — no templates or
5//! placeholders.
6
7/// Maximum number of hints to return from any generator.
8const MAX_HINTS: usize = 5;
9
10/// Prefix used by lint for frontmatter parse errors. Shared between
11/// `commands::lint` and the hint generator to avoid brittle string coupling.
12pub(crate) const PARSE_ERROR_PREFIX: &str = "could not parse frontmatter";
13
14/// A single drill-down hint: a concrete command plus a short human-readable description.
15#[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
30/// Identifies which command produced the output.
31pub 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
53/// Global flags to propagate into generated hint commands.
54///
55/// Each `Option` field is `Some` only when the user passed the flag explicitly
56/// on the CLI. Values that came from `.hyalo.toml` config are omitted so that
57/// the copy-pasted hint command inherits the same config automatically.
58pub struct HintContext {
59    pub source: HintSource,
60    /// `None` means "." (default) or came from config — omit from hints.
61    pub dir: Option<String>,
62    pub glob: Vec<String>,
63    /// Explicit `--format` from CLI (not from config).
64    pub format: Option<String>,
65    /// Explicit `--hints` from CLI (not from config).
66    pub hints: bool,
67    // Find context
68    pub fields: Vec<String>,
69    pub sort: Option<String>,
70    pub has_limit: bool,
71    pub has_body_search: bool,
72    /// The actual body-search pattern string, when a body search was issued.
73    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    /// Set when the query was produced by `--view <name>`; suppresses the
81    /// "save as view" hint to avoid suggesting the user save a view they
82    /// already have.
83    pub view_name: Option<String>,
84    /// Task selector used: "all", "section:<name>", or "lines" (for multi-line).
85    /// `None` means single-line or no task context.
86    pub task_selector: Option<String>,
87    // Mutation context
88    pub dry_run: bool,
89    // Index context
90    pub index_path: Option<String>,
91    // Links-auto context (for replaying the exact preview scope in hints)
92    pub auto_link_file: Option<String>,
93    pub auto_link_min_length: Option<usize>,
94    pub auto_link_exclude_titles: Vec<String>,
95}
96
97/// Common global flags captured once per command dispatch and threaded into
98/// every `HintContext`. Avoids repeating the same three field assignments in
99/// every `match` arm of `run.rs`.
100pub struct CommonHintFlags {
101    /// `--dir` value when explicitly passed on the CLI; `None` when inherited
102    /// from `.hyalo.toml` (the hint can omit it and rely on config).
103    pub dir: Option<String>,
104    /// `--format` value when explicitly passed on the CLI.
105    pub format: Option<String>,
106    /// Whether `--hints` was explicitly passed on the CLI.
107    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    /// Construct a `HintContext` with the common global flags pre-populated.
140    ///
141    /// Equivalent to calling `new(source)` followed by assigning `dir`,
142    /// `format`, and `hints` — extracted here so every `match` arm in
143    /// `run.rs` does not repeat those three lines.
144    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/// Generate concrete drill-down hints from a command's JSON output.
154///
155/// `total` is the real count of items (may exceed the number of items in `data`
156/// when output was truncated by a limit). `None` means the command doesn't
157/// produce a list with a total.
158///
159/// Returns at most [`MAX_HINTS`] [`Hint`]s, each with a human-readable description
160/// and an executable `hyalo` command (`cmd`).
161#[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
188// ---------------------------------------------------------------------------
189// Command builder
190// ---------------------------------------------------------------------------
191
192/// Push the global flags that were explicitly passed on the CLI.
193fn 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
207/// Build a command that intentionally omits `--glob` (for file-specific hints).
208fn 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
217/// Build a command where `file_arg` is a positional file path following `subcommand_args`.
218///
219/// If `file_arg` starts with `-`, emits `--file <path>` instead of the bare positional
220/// to prevent clap from interpreting the filename as a flag.
221fn 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
239/// Build a command that propagates `--glob` when present.
240fn 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
253/// Like `build_command_with_glob` but also preserves `--file` / positional file
254/// targets so that lint hints don't widen scope from a single file to the whole
255/// vault.
256fn 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
272/// Build a `find` command that preserves the caller's existing filters (property,
273/// tag, task, file targets) plus `--glob`, then appends `extra_args`.  Use this for
274/// hints like sort and limit that refine the current query without changing its scope.
275fn 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
304/// Build a `find` command that replaces the body search pattern with `new_pattern`
305/// while preserving all other existing filters (property, tag, task, file targets,
306/// glob). The pattern is inserted as a positional argument immediately after `find`.
307fn 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
334/// Push a file argument that is safe as a positional arg.
335///
336/// If the filename starts with `-`, clap would interpret it as a flag.
337/// In that case, emit `--file <path>` (flag form) instead of the bare positional.
338fn 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
347/// Wrap a string in single-quotes if it contains any shell-special characters.
348///
349/// Uses an allowlist of safe characters — anything not in the list triggers quoting.
350/// Single-quoting avoids variable expansion and is safer than double-quoting.
351pub 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        // In single-quoted strings, the only character that needs escaping is '
358        // which is done by ending the quote, adding an escaped quote, and reopening.
359        format!("'{}'", s.replace('\'', "'\\''"))
360    } else {
361        s.to_owned()
362    }
363}
364
365// ---------------------------------------------------------------------------
366// Status priority helpers
367// ---------------------------------------------------------------------------
368
369/// Priority rank for a status value: lower = more interesting.
370fn 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
390// ---------------------------------------------------------------------------
391// Shared helpers
392// ---------------------------------------------------------------------------
393
394/// Extract the first modified file path from mutation output (single object or array).
395fn 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
409// ---------------------------------------------------------------------------
410// Per-source hint generators
411// ---------------------------------------------------------------------------
412
413fn 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    // Suggest lint early when there are schema violations — high priority so it
426    // is not pushed out by orphans/dead-ends/broken-links hints.
427    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    // Suggest find --task todo if there are open tasks.
445    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    // Suggest find --orphan if there are orphan files.
463    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    // Suggest find --dead-end if there are dead-end files.
475    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    // Suggest find --broken-links if there are broken links.
487    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    // When schema is defined but no violations, or when there's still room,
506    // add the general lint / types hints.
507    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    // Pick 1-2 most interesting status values.
531    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    // When output was truncated by the default limit (not an explicit --limit), suggest
566    // showing all results.
567    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    // Sort by count descending, take top 3.
580    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
606/// Slugify a string to the charset valid for view names: `[a-z0-9_-]`.
607/// Replaces invalid chars with `-`, collapses runs of `-`, and trims leading/trailing `-`.
608fn 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            // Replace any non-allowed char with a hyphen (collapsed below).
615            if !out.ends_with('-') {
616                out.push('-');
617            }
618        }
619    }
620    out.trim_matches('-').to_owned()
621}
622
623/// Derive a short, human-readable name from the active filters.
624fn 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            // Regex filter (K~=pattern): use the key, not the pattern.
630            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    // Trim any trailing `-` left by truncation mid-word.
653    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
661/// Build the `hyalo views set <name> <filters…>` command string.
662fn 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
683/// Suggest saving the current query as a view when at least two
684/// view-serializable filter dimensions are active and the query did not
685/// itself come from a view. Excludes body/regex search since the actual
686/// pattern value is not available in `HintContext`.
687fn suggest_save_as_view(ctx: &HintContext) -> Option<Hint> {
688    if ctx.view_name.is_some() {
689        return None;
690    }
691
692    // Only count filters that can be round-tripped into a `views set` command.
693    // Body/regex search is excluded because `views set` does not support them,
694    // not the actual pattern string.
695    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    // find returns a bare array as the raw command output (the envelope is built later).
709    let Some(results) = data.as_array() else {
710        return vec![];
711    };
712
713    if results.is_empty() {
714        // When a multi-word BM25 search returns nothing, suggest trying OR instead.
715        // Skip if the query already contains quotes (phrase search) — splitting on
716        // whitespace would produce malformed tokens like `"exact` and `phrase"`.
717        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    // --- Single-result hints ---
743    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    // --- Task bulk operation hints ---
761    // When find results target a single file and include task data, suggest bulk task ops.
762    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    // --- Broad query → suggest summary ---
797    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    // --- Show-all hint when default limit truncated output ---
816    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    // --- Narrowing for many results (>5) ---
830    if result_count > 5 {
831        // Tag narrowing (skip tags already filtered on).
832        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        // Collect status property frequencies — skip statuses already filtered on.
847        // Handles both scalar and array-valued status properties.
848        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            // Yield individual &str values from scalar or array status.
855            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        // Pick the most common tag (if any results have tags).
872        // Break ties alphabetically for deterministic output.
873        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        // Pick the most interesting status value (prefer active/planned over completed).
887        let mut status_vec: Vec<(&str, usize, u8)> = status_counts
888            .iter()
889            .map(|(v, c)| (*v, *c, status_priority(v)))
890            .collect();
891        // Sort by priority (ascending), then count (descending), then name (ascending).
892        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        // Sort suggestion (only if not already sorting).
908        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        // Limit suggestion: suggest --limit 10 when not truncated and no explicit limit.
922        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    // Suggest saving as a view for non-trivial queries (independent of result count).
934    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    // Body search → regex suggestion is intentionally omitted.
942    // We cannot produce a concrete regex without knowing the user's intent,
943    // and a placeholder like `'pattern'` would violate our no-templates contract.
944
945    // Suggest phrase search when body search has multiple words and many results.
946    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    // Suggest `links fix` when results contain broken links (e.g. from --broken-links).
969    // Broken links are serialised with `"path": null` (never omitted) by find's output.
970    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    // tags summary returns a bare array [{name, count}, ...].
998    let Some(tags_arr) = data.as_array() else {
999        return vec![];
1000    };
1001
1002    let mut hints = Vec::new();
1003
1004    // When output was truncated by the default limit (not an explicit --limit), suggest
1005    // showing all results.
1006    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    // Sort by count descending, take top 3.
1019    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    // When output was truncated by the default limit (not an explicit --limit), suggest
1097    // showing all results.
1098    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    // Suggest reading the first backlink source.
1128    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
1176/// Check if task output data (single or array) contains any open (not done) tasks.
1177fn task_result_has_open(data: &serde_json::Value) -> bool {
1178    // Array case (bulk result)
1179    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    // Single task case
1185    data.get("done") == Some(&serde_json::Value::Bool(false))
1186}
1187
1188/// Hints for `task read` — suggest toggling or viewing remaining tasks.
1189fn hints_for_task_read(ctx: &HintContext, data: &serde_json::Value) -> Vec<Hint> {
1190    let mut hints = Vec::new();
1191
1192    // For bulk reads (--all / --section), suggest toggling the same scope.
1193    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        // For "all" and "section:" selectors, return early — the bulk hints are sufficient.
1216        // For "lines" selector, fall through to the single-task hint path which handles
1217        // individual line-based suggestions.
1218        if selector != "lines" {
1219            return hints;
1220        }
1221    }
1222
1223    // Single-task read path (backward compatible).
1224    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        // Suggest reading the scope that was just mutated.
1264        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        // Rebuild the exact command from the preview, preserving all
1343        // scope-narrowing flags so the apply doesn't widen the mutation set.
1344        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        // Append --file and --exclude-title after the builder (they are not
1355        // glob-related and aren't handled by build_command_with_glob).
1356        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    // Use bare `--index` (defaults to .hyalo-index in vault dir) for the default path.
1376    // Only include the explicit path when the index was created at a non-default location.
1377    let index_path = data
1378        .get("path")
1379        .and_then(|p| p.as_str())
1380        .or(ctx.index_path.as_deref());
1381
1382    // Only treat as default when no path was reported or it's the bare default name.
1383    // Custom paths like `sub/.hyalo-index` must emit the explicit path in the hint.
1384    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    // When output was truncated by the default limit, suggest showing all.
1415    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    // When in dry-run mode and there are pending fixes, suggest applying them.
1431    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    // When there are violations and we're not already in fix mode, suggest fixing.
1448    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    // When there are unfixable parse errors, suggest listing all violations.
1472    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    // Always suggest listing defined types.
1497    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            // Suggest showing the first listed type.
1518            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    // --- shell_quote ---
1605
1606    #[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    // --- build_command ---
1637
1638    #[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    // --- status_priority ---
1666
1667    #[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    // --- hints_for_summary ---
1676
1677    #[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        // in-progress should appear before completed
1752        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        // completed may appear (only if limit not reached) or not — but in-progress must come first
1756        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    // --- hints_for_properties_summary ---
1782
1783    #[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        // author should not appear (rank 4)
1798        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    // --- hints_for_find ---
1818
1819    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        // 6 results; rust appears 4 times, cli 2 times — rust should be suggested.
1866        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        // 6 results; in-progress is more interesting than completed.
1888        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        // 6 results, none with tags; should still suggest status narrowing.
1910        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        // No --tag hints when no tags exist.
1927        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        // 10 results with varied tags and statuses.
1937        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        // 6 results to trigger sort/limit hints.
1951        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    // --- flag propagation ---
1991
1992    #[test]
1993    fn dir_flag_propagated_to_all_hints() {
1994        let c = ctx_with_dir(HintSource::TagsSummary, "/vault");
1995        // tags summary returns a bare array [{name, count}, ...]
1996        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    // --- new generator tests ---
2003
2004    #[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        // 15 results, no filters
2212        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        // Should NOT suggest narrowing by --tag rust (already filtered).
2248        // Sort/limit hints may legitimately include --tag rust as a preserved filter,
2249        // so only check narrowing hints (those whose description starts with "Narrow").
2250        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    // --- hints_for_lint ---
2350
2351    #[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    // --- hints_for_types ---
2408
2409    #[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}