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