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