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