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