Skip to main content

runex_core/
doctor.rs

1use std::path::Path;
2
3use crate::model::{Config, TriggerKey};
4use crate::sanitize::{sanitize_for_display, sanitize_multiline_for_display};
5use serde::Serialize;
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum CheckStatus {
10    Ok,
11    Warn,
12    Error,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
16pub struct Check {
17    pub name: String,
18    pub status: CheckStatus,
19    pub detail: String,
20    /// Full detail shown only with --verbose. None when same as detail.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub detail_verbose: Option<String>,
23}
24
25#[derive(Debug, Clone, Serialize)]
26pub struct DiagResult {
27    pub checks: Vec<Check>,
28}
29
30impl DiagResult {
31    pub fn is_healthy(&self) -> bool {
32        self.checks.iter().all(|c| c.status != CheckStatus::Error)
33    }
34}
35
36/// Informational facts about the host environment that `runex doctor`
37/// surfaces alongside the config-validation checks.
38///
39/// The struct exists (rather than passing bare values) so future
40/// platform diagnostics — XDG paths, registry overrides, shell autodetect
41/// hints — can be added without churning every call site.
42#[derive(Debug, Clone, Default)]
43pub struct DoctorEnvInfo {
44    /// Summary of the augmented command-resolution PATH used on Windows.
45    /// `None` on non-Windows or when the caller can't compute it. When
46    /// present, `diagnose` emits an informational `effective_search_path`
47    /// check so a degraded process PATH (clink-style) is visible to the
48    /// user before they hit a `command:foo not found` warning.
49    pub effective_search_path: Option<EffectiveSearchPathSummary>,
50
51    /// The output `runex export clink` would produce *now*. When
52    /// `Some`, `diagnose` compares it against the on-disk `runex.lua`
53    /// and warns if the two have drifted — the canonical sign that the
54    /// user upgraded runex but never re-ran `runex init clink`. `None`
55    /// skips the check (e.g. on platforms without clink, or when the
56    /// caller can't render the export).
57    pub clink_export_for_drift_check: Option<String>,
58
59    /// Per-shell rcfile marker checks. The caller decides which shells
60    /// the user actually has installed; entries set to `true` produce
61    /// a `integration:<shell>` row in the doctor output.
62    pub check_rcfile_markers: RcfileMarkerSelection,
63}
64
65/// Which shells should have their rcfile checked for the runex init
66/// marker. The struct exists (rather than a bare `Vec<Shell>`) so
67/// future per-shell options (skip-if-missing, custom path overrides)
68/// can be added without churning callers.
69#[derive(Debug, Clone, Default)]
70pub struct RcfileMarkerSelection {
71    pub bash: bool,
72    pub zsh: bool,
73    pub pwsh: bool,
74    pub nu: bool,
75}
76
77impl RcfileMarkerSelection {
78    /// Enable checks for every shell that has an rcfile concept
79    /// (i.e. all of them except clink).
80    pub fn all() -> Self {
81        Self { bash: true, zsh: true, pwsh: true, nu: true }
82    }
83}
84
85/// Per-source breakdown of the merged PATH `runex hook` actually uses
86/// when resolving `when_command_exists` entries.
87#[derive(Debug, Clone)]
88pub struct EffectiveSearchPathSummary {
89    pub from_process: usize,
90    pub from_user_registry: usize,
91    pub from_system_registry: usize,
92}
93
94impl EffectiveSearchPathSummary {
95    pub fn total(&self) -> usize {
96        self.from_process + self.from_user_registry + self.from_system_registry
97    }
98}
99
100/// Build the informational `effective_search_path` check.
101///
102/// Uses `Warn` status only when the process PATH itself is empty
103/// (extremely unusual — almost certainly a misconfigured environment),
104/// otherwise stays `Ok` and reports the breakdown so users can spot
105/// "process=2, +user=42, +system=15" patterns that suggest the parent
106/// process was launched with a degraded PATH.
107fn check_effective_search_path(s: &EffectiveSearchPathSummary) -> Check {
108    let total = s.total();
109    let detail = format!(
110        "{} entries (process={}, +user={}, +system={})",
111        total, s.from_process, s.from_user_registry, s.from_system_registry
112    );
113    Check {
114        name: "effective_search_path".into(),
115        status: if s.from_process == 0 {
116            // No process PATH at all is a real anomaly — flag it.
117            CheckStatus::Warn
118        } else {
119            CheckStatus::Ok
120        },
121        detail,
122        detail_verbose: None,
123    }
124}
125
126fn check_config_file(config_path: &Path) -> Check {
127    let exists = config_path.exists();
128    Check {
129        name: "config_file".into(),
130        status: if exists { CheckStatus::Ok } else { CheckStatus::Error },
131        detail: if exists {
132            format!("found: {}", sanitize_for_display(&config_path.display().to_string()))
133        } else {
134            format!("not found: {}", sanitize_for_display(&config_path.display().to_string()))
135        },
136        detail_verbose: None,
137    }
138}
139
140fn check_config_parse(config: Option<&Config>, parse_error: Option<&str>) -> Check {
141    let (detail, detail_verbose) = if config.is_some() {
142        ("config loaded successfully".into(), None)
143    } else if let Some(e) = parse_error {
144        let first_line = e.lines().next().unwrap_or(e);
145        let short = format!("failed to load config: {}", sanitize_for_display(first_line));
146        let full = format!("failed to load config: {}", sanitize_multiline_for_display(e));
147        let verbose = if full != short { Some(full) } else { None };
148        (short, verbose)
149    } else {
150        ("failed to load config".into(), None)
151    };
152    Check { name: "config_parse".into(), status: if config.is_some() { CheckStatus::Ok } else { CheckStatus::Error }, detail, detail_verbose }
153}
154
155fn check_abbr_quality(config: &Config) -> Vec<Check> {
156    let mut checks = Vec::new();
157    for (i, abbr) in config.abbr.iter().enumerate() {
158        if abbr.key.is_empty() {
159            checks.push(Check {
160                name: format!("abbr[{i}].empty_key"),
161                status: CheckStatus::Warn,
162                detail: format!("rule #{n} has an empty key — it will never match", n = i + 1),
163                detail_verbose: None,
164            });
165        }
166        // Self-loop: check all expand variants for key == expand.
167        let self_loop = abbr.expand.all_values().iter().any(|&v| v == abbr.key);
168        if self_loop {
169            checks.push(Check {
170                name: format!("abbr[{i}].self_loop"),
171                status: CheckStatus::Warn,
172                detail: format!(
173                    "rule #{n} key == expand ('{key}') — this rule is always skipped",
174                    n = i + 1,
175                    key = sanitize_for_display(&abbr.key)
176                ),
177                detail_verbose: None,
178            });
179        }
180    }
181    checks
182}
183
184fn check_when_command_exists<F>(config: &Config, command_exists: &F) -> Vec<Check>
185where
186    F: Fn(&str) -> bool,
187{
188    let mut checks = Vec::new();
189    let mut seen = std::collections::HashSet::new();
190    for abbr in &config.abbr {
191        if let Some(cmds) = &abbr.when_command_exists {
192            for cmd_list in cmds.all_values() {
193                for cmd in cmd_list {
194                    // Deduplicate checks for the same command name.
195                    if !seen.insert(cmd.clone()) {
196                        continue;
197                    }
198                    let exists = command_exists(cmd);
199                    checks.push(Check {
200                        name: format!("command:{}", sanitize_for_display(cmd)),
201                        status: if exists { CheckStatus::Ok } else { CheckStatus::Warn },
202                        detail: if exists {
203                            format!(
204                                "'{}' found (required by '{}')",
205                                sanitize_for_display(cmd),
206                                sanitize_for_display(&abbr.key)
207                            )
208                        } else {
209                            format!(
210                                "'{}' not found (required by '{}')",
211                                sanitize_for_display(cmd),
212                                sanitize_for_display(&abbr.key)
213                            )
214                        },
215                        detail_verbose: None,
216                    });
217                }
218            }
219        }
220    }
221    checks
222}
223
224fn check_keybind(config: &Config) -> Vec<Check> {
225    let mut checks = Vec::new();
226    let si = &config.keybind.self_insert;
227    let bash_si = si.bash.or(si.default);
228    let zsh_si = si.zsh.or(si.default);
229    if bash_si == Some(TriggerKey::ShiftSpace) || zsh_si == Some(TriggerKey::ShiftSpace) {
230        checks.push(Check {
231            name: "keybind.self_insert".into(),
232            status: CheckStatus::Warn,
233            detail:
234                "self_insert = \"shift-space\" has no effect in bash/zsh (Shift+Space is terminal-dependent); use \"alt-space\" for cross-shell support".into(),
235            detail_verbose: None,
236        });
237    }
238    checks
239}
240
241/// Levenshtein distance between two strings (for "did you mean?" suggestions).
242fn levenshtein(a: &str, b: &str) -> usize {
243    let (a, b) = (a.as_bytes(), b.as_bytes());
244    let mut prev: Vec<usize> = (0..=b.len()).collect();
245    let mut curr = vec![0; b.len() + 1];
246    for i in 1..=a.len() {
247        curr[0] = i;
248        for j in 1..=b.len() {
249            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
250            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
251        }
252        std::mem::swap(&mut prev, &mut curr);
253    }
254    prev[b.len()]
255}
256
257/// Find the closest match from `candidates` for `name` (Levenshtein distance ≤ 2).
258fn suggest_similar(name: &str, candidates: &[&str]) -> Option<String> {
259    candidates
260        .iter()
261        .filter_map(|&c| {
262            let d = levenshtein(name, c);
263            if d <= 2 && d > 0 { Some((c, d)) } else { None }
264        })
265        .min_by_key(|&(_, d)| d)
266        .map(|(c, _)| c.to_string())
267}
268
269/// Known top-level TOML keys in config.
270const KNOWN_TOP_LEVEL_KEYS: &[&str] = &["version", "keybind", "precache", "abbr"];
271
272/// Known keys inside an `[[abbr]]` table.
273const KNOWN_ABBR_KEYS: &[&str] = &["key", "expand", "when_command_exists"];
274
275/// Known keys inside `[keybind]`.
276const KNOWN_KEYBIND_KEYS: &[&str] = &["trigger", "self_insert"];
277
278/// Known keys inside a keybind subtable (e.g. `[keybind.trigger]`).
279const KNOWN_KEYBIND_SUB_KEYS: &[&str] = &["default", "bash", "zsh", "pwsh", "nu"];
280
281/// Known keys inside `[precache]`.
282const KNOWN_PRECACHE_KEYS: &[&str] = &["path_only"];
283
284/// Check for unknown fields in the raw TOML source (strict mode).
285///
286/// Parses the config as a raw TOML table and compares keys against whitelists.
287/// Returns Warn checks for each unknown field, with "did you mean?" suggestions.
288/// Check for rules rejected by per-field validation.
289///
290/// Unlike `check_config_parse`, which surfaces only the first `ConfigError`
291/// from `parse_config`, this walks every rule and reports *all* validation
292/// failures with field-path diagnostics (e.g. `abbr[3].expand.pwsh`).
293///
294/// Config loading still stops at the first error — these warnings are
295/// observability only, not a lenient-load mode.
296pub fn check_rejected_rules(config_source: &str) -> Vec<Check> {
297    // If deserialization fails (syntax / unsupported version), check_config_parse
298    // already reports it. Emit nothing here.
299    let Ok(config) = crate::config::parse_config_lenient(config_source) else {
300        return vec![];
301    };
302    let issues = crate::config::collect_validation_issues(&config);
303    if issues.is_empty() {
304        return vec![];
305    }
306
307    let mut checks = Vec::with_capacity(issues.len() + 1);
308
309    // Summary check first, so the reader sees the semantics before the details.
310    checks.push(Check {
311        name: "config_rejected_rules".into(),
312        status: CheckStatus::Warn,
313        detail: format!(
314            "{} invalid abbr field(s) found; config loading still stops at the first one",
315            issues.len()
316        ),
317        detail_verbose: None,
318    });
319
320    for issue in issues {
321        let check = match &issue {
322            crate::config::ValidationIssue::Config { .. } => Check {
323                name: "config_validation".into(),
324                status: CheckStatus::Warn,
325                detail: format!("config rejected: {}", issue.reason_text()),
326                detail_verbose: None,
327            },
328            crate::config::ValidationIssue::Rule { rule_index, field_path, .. } => {
329                let safe_path = sanitize_for_display(field_path);
330                Check {
331                    name: format!("config_validation.abbr[{rule_index}].{safe_path}"),
332                    status: CheckStatus::Warn,
333                    detail: format!(
334                        "rule #{rule_index} field '{safe_path}' rejected: {}",
335                        issue.reason_text(),
336                    ),
337                    detail_verbose: None,
338                }
339            }
340        };
341        checks.push(check);
342    }
343    checks
344}
345
346/// Strict-mode check: warn when the deprecated `[precache]` section is
347/// present in the config. The section was used by the legacy shell template
348/// to emit a startup precache helper; the hook-based bootstraps consult the
349/// config at keypress time so the section now has no run-time effect.
350///
351/// Returns an empty vec when the TOML is unparseable; that case is reported
352/// by `check_config_parse` already.
353pub fn check_precache_deprecation(config_source: &str) -> Vec<Check> {
354    let table: toml::Table = match config_source.parse() {
355        Ok(t) => t,
356        Err(_) => return vec![],
357    };
358    if !table.contains_key("precache") {
359        return vec![];
360    }
361    vec![Check {
362        name: "precache_deprecation".into(),
363        status: CheckStatus::Warn,
364        detail: "[precache] is deprecated and has no effect since the shell integration moved to runtime hook calls. Remove the section to silence this warning.".into(),
365        detail_verbose: None,
366    }]
367}
368
369pub fn check_unknown_fields(config_source: &str) -> Vec<Check> {
370    let table: toml::Table = match config_source.parse() {
371        Ok(t) => t,
372        Err(_) => return vec![], // parse errors are caught by check_config_parse
373    };
374
375    let mut checks = Vec::new();
376
377    // Top-level keys
378    for key in table.keys() {
379        if !KNOWN_TOP_LEVEL_KEYS.contains(&key.as_str()) {
380            let suggestion = suggest_similar(key, KNOWN_TOP_LEVEL_KEYS);
381            let detail = match suggestion {
382                Some(s) => format!("unknown top-level field '{}' (did you mean '{}'?)", sanitize_for_display(key), s),
383                None => format!("unknown top-level field '{}'", sanitize_for_display(key)),
384            };
385            checks.push(Check {
386                name: format!("strict.unknown_field.{}", sanitize_for_display(key)),
387                status: CheckStatus::Warn,
388                detail,
389                detail_verbose: None,
390            });
391        }
392    }
393
394    // [keybind] subtable keys
395    if let Some(toml::Value::Table(kb)) = table.get("keybind") {
396        for key in kb.keys() {
397            if !KNOWN_KEYBIND_KEYS.contains(&key.as_str()) {
398                let suggestion = suggest_similar(key, KNOWN_KEYBIND_KEYS);
399                let detail = match suggestion {
400                    Some(s) => format!("unknown keybind field '{}' (did you mean '{}'?)", sanitize_for_display(key), s),
401                    None => format!("unknown keybind field '{}'", sanitize_for_display(key)),
402                };
403                checks.push(Check {
404                    name: format!("strict.unknown_field.keybind.{}", sanitize_for_display(key)),
405                    status: CheckStatus::Warn,
406                    detail,
407                    detail_verbose: None,
408                });
409            } else if let Some(toml::Value::Table(sub)) = kb.get(key) {
410                // Check keybind subtable keys (e.g. [keybind.trigger])
411                for sub_key in sub.keys() {
412                    if !KNOWN_KEYBIND_SUB_KEYS.contains(&sub_key.as_str()) {
413                        let suggestion = suggest_similar(sub_key, KNOWN_KEYBIND_SUB_KEYS);
414                        let detail = match suggestion {
415                            Some(s) => format!("unknown keybind.{} field '{}' (did you mean '{}'?)", key, sanitize_for_display(sub_key), s),
416                            None => format!("unknown keybind.{} field '{}'", key, sanitize_for_display(sub_key)),
417                        };
418                        checks.push(Check {
419                            name: format!("strict.unknown_field.keybind.{}.{}", key, sanitize_for_display(sub_key)),
420                            status: CheckStatus::Warn,
421                            detail,
422                            detail_verbose: None,
423                        });
424                    }
425                }
426            }
427        }
428    }
429
430    // [precache] keys
431    if let Some(toml::Value::Table(pc)) = table.get("precache") {
432        for key in pc.keys() {
433            if !KNOWN_PRECACHE_KEYS.contains(&key.as_str()) {
434                let suggestion = suggest_similar(key, KNOWN_PRECACHE_KEYS);
435                let detail = match suggestion {
436                    Some(s) => format!("unknown precache field '{}' (did you mean '{}'?)", sanitize_for_display(key), s),
437                    None => format!("unknown precache field '{}'", sanitize_for_display(key)),
438                };
439                checks.push(Check {
440                    name: format!("strict.unknown_field.precache.{}", sanitize_for_display(key)),
441                    status: CheckStatus::Warn,
442                    detail,
443                    detail_verbose: None,
444                });
445            }
446        }
447    }
448
449    // [[abbr]] entries
450    if let Some(toml::Value::Array(abbrs)) = table.get("abbr") {
451        for (i, entry) in abbrs.iter().enumerate() {
452            if let toml::Value::Table(abbr_table) = entry {
453                for key in abbr_table.keys() {
454                    if !KNOWN_ABBR_KEYS.contains(&key.as_str()) {
455                        let suggestion = suggest_similar(key, KNOWN_ABBR_KEYS);
456                        let detail = match suggestion {
457                            Some(s) => format!(
458                                "unknown field '{}' in abbr[{}] (did you mean '{}'?)",
459                                sanitize_for_display(key), i + 1, s
460                            ),
461                            None => format!("unknown field '{}' in abbr[{}]", sanitize_for_display(key), i + 1),
462                        };
463                        checks.push(Check {
464                            name: format!("strict.unknown_field.abbr[{}].{}", i, sanitize_for_display(key)),
465                            status: CheckStatus::Warn,
466                            detail,
467                            detail_verbose: None,
468                        });
469                    }
470                }
471            }
472        }
473    }
474
475    checks
476}
477
478/// Check for unreachable duplicate rules (strict mode).
479///
480/// A rule is unreachable if an earlier rule with the same key has no
481/// `when_command_exists` condition — it will always match first, making
482/// all later rules with that key dead code.
483pub fn check_unreachable_duplicates(config: &Config) -> Vec<Check> {
484    let mut checks = Vec::new();
485    // Track keys where an unconditional rule has been seen.
486    let mut unconditional_keys: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
487
488    for (i, abbr) in config.abbr.iter().enumerate() {
489        if let Some(&first_rule) = unconditional_keys.get(abbr.key.as_str()) {
490            // A previous unconditional rule already matches this key.
491            checks.push(Check {
492                name: format!("strict.unreachable.abbr[{}]", i),
493                status: CheckStatus::Warn,
494                detail: format!(
495                    "rule #{} ('{}') is unreachable — rule #{} has the same key with no condition and always matches first",
496                    i + 1,
497                    sanitize_for_display(&abbr.key),
498                    first_rule + 1,
499                ),
500                detail_verbose: None,
501            });
502        } else if abbr.when_command_exists.is_none() {
503            // This is an unconditional rule — record it.
504            unconditional_keys.insert(&abbr.key, i);
505        }
506    }
507    checks
508}
509
510/// Run environment diagnostics.
511///
512/// `config` is `None` when config loading failed (parse error, etc.).
513/// `parse_error` carries the error message when `config` is `None` due to a parse failure.
514/// `command_exists` is injected for testability.
515pub fn diagnose<F>(
516    config_path: &Path,
517    config: Option<&Config>,
518    parse_error: Option<&str>,
519    env_info: &DoctorEnvInfo,
520    command_exists: F,
521) -> DiagResult
522where
523    F: Fn(&str) -> bool,
524{
525    let mut checks = Vec::new();
526    checks.push(check_config_file(config_path));
527    checks.push(check_config_parse(config, parse_error));
528    if let Some(summary) = env_info.effective_search_path.as_ref() {
529        // Emit before the per-command checks so users see the search PATH
530        // context above any "command:foo not found" warnings that may
531        // follow.
532        checks.push(check_effective_search_path(summary));
533    }
534    checks.extend(integration_marker_checks(&env_info.check_rcfile_markers));
535    if let Some(export) = env_info.clink_export_for_drift_check.as_deref() {
536        let r = crate::integration_check::check_clink_lua_freshness(
537            export,
538            &crate::integration_check::default_clink_lua_paths(),
539        );
540        checks.push(integration_check_to_check(r));
541    }
542    if let Some(cfg) = config {
543        checks.extend(check_keybind(cfg));
544        checks.extend(check_abbr_quality(cfg));
545        checks.extend(check_when_command_exists(cfg, &command_exists));
546    }
547    DiagResult { checks }
548}
549
550/// Convert an [`integration_check::IntegrationCheck`] into the doctor
551/// `Check` shape. `Outdated` becomes `Warn`, `Missing` becomes `Warn`
552/// (we don't escalate to Error: a stale or missing rcfile shouldn't
553/// fail `doctor` outright — the user's shell still works).
554fn integration_check_to_check(r: crate::integration_check::IntegrationCheck) -> Check {
555    use crate::integration_check::IntegrationCheck;
556    let (status, name, detail) = match r {
557        IntegrationCheck::Ok { name, detail } => (CheckStatus::Ok, name, detail),
558        IntegrationCheck::Outdated { name, detail, .. } => (CheckStatus::Warn, name, detail),
559        IntegrationCheck::Missing { name, detail } => (CheckStatus::Warn, name, detail),
560        IntegrationCheck::Skipped { name, detail } => (CheckStatus::Ok, name, detail),
561    };
562    Check { name, status, detail, detail_verbose: None }
563}
564
565/// Run the rcfile-marker check for each shell selected by the caller.
566fn integration_marker_checks(sel: &RcfileMarkerSelection) -> Vec<Check> {
567    use crate::integration_check::check_rcfile_marker;
568    use crate::shell::Shell;
569    let mut out = Vec::new();
570    if sel.bash {
571        out.push(integration_check_to_check(check_rcfile_marker(Shell::Bash, None)));
572    }
573    if sel.zsh {
574        out.push(integration_check_to_check(check_rcfile_marker(Shell::Zsh, None)));
575    }
576    if sel.pwsh {
577        out.push(integration_check_to_check(check_rcfile_marker(Shell::Pwsh, None)));
578    }
579    if sel.nu {
580        out.push(integration_check_to_check(check_rcfile_marker(Shell::Nu, None)));
581    }
582    out
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::model::{Abbr, Config};
589    use std::io::Write;
590
591    fn test_config(abbrs: Vec<Abbr>) -> Config {
592        Config {
593            version: 1,
594            keybind: crate::model::KeybindConfig::default(),
595            precache: crate::model::PrecacheConfig::default(),
596            abbr: abbrs,
597        }
598    }
599
600    fn abbr_when(key: &str, exp: &str, cmds: Vec<&str>) -> Abbr {
601        Abbr {
602            key: key.into(),
603            expand: crate::model::PerShellString::All(exp.into()),
604            when_command_exists: Some(crate::model::PerShellCmds::All(
605                cmds.into_iter().map(String::from).collect(),
606            )),
607        }
608    }
609
610    fn abbr(key: &str, exp: &str) -> Abbr {
611        Abbr {
612            key: key.into(),
613            expand: crate::model::PerShellString::All(exp.into()),
614            when_command_exists: None,
615        }
616    }
617
618    mod diagnostics {
619        use super::*;
620
621    #[test]
622    fn all_healthy() {
623        let dir = tempfile::tempdir().unwrap();
624        let path = dir.path().join("config.toml");
625        let mut f = std::fs::File::create(&path).unwrap();
626        writeln!(f, "version = 1").unwrap();
627
628        let cfg = test_config(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
629        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| true);
630
631        assert!(result.is_healthy());
632        assert_eq!(result.checks[0].status, CheckStatus::Ok); // file exists
633        assert_eq!(result.checks[1].status, CheckStatus::Ok); // config parsed
634        assert_eq!(result.checks[2].status, CheckStatus::Ok); // command found
635    }
636
637    #[test]
638    fn config_file_missing() {
639        let path = std::path::PathBuf::from("/nonexistent/config.toml");
640        let result = diagnose(&path, None, None, &DoctorEnvInfo::default(), |_| true);
641
642        assert!(!result.is_healthy());
643        assert_eq!(result.checks[0].status, CheckStatus::Error);
644        assert_eq!(result.checks[1].status, CheckStatus::Error);
645    }
646
647    #[test]
648    fn config_parse_error_detail_shown() {
649        let path = std::path::PathBuf::from("/nonexistent/config.toml");
650        let result = diagnose(&path, None, Some("TOML parse error at line 4"), &DoctorEnvInfo::default(), |_| true);
651
652        let parse_check = result.checks.iter().find(|c| c.name == "config_parse").unwrap();
653        assert_eq!(parse_check.status, CheckStatus::Error);
654        assert!(parse_check.detail.contains("TOML parse error at line 4"),
655            "detail must include the parse error message: {:?}", parse_check.detail);
656    }
657
658    #[test]
659    fn config_parse_multiline_error_splits_detail_and_verbose() {
660        let path = std::path::PathBuf::from("/nonexistent/config.toml");
661        let multiline = "TOML parse error at line 4, column 11\n  |\n4 | trigger = \"space\"\n  |           ^^^^^^^\ninvalid type";
662        let result = diagnose(&path, None, Some(multiline), &DoctorEnvInfo::default(), |_| true);
663
664        let parse_check = result.checks.iter().find(|c| c.name == "config_parse").unwrap();
665        assert_eq!(parse_check.status, CheckStatus::Error);
666
667        let detail_lines: Vec<&str> = parse_check.detail.lines().collect();
668        assert_eq!(detail_lines.len(), 1,
669            "detail must be a single line, got: {:?}", parse_check.detail);
670        assert!(parse_check.detail.contains("TOML parse error at line 4, column 11"),
671            "detail must contain the first line: {:?}", parse_check.detail);
672
673        let verbose = parse_check.detail_verbose.as_deref()
674            .expect("detail_verbose must be Some for multiline errors");
675        assert!(verbose.contains("invalid type"),
676            "detail_verbose must contain later lines: {:?}", verbose);
677    }
678
679    #[test]
680    fn config_parse_single_line_error_has_no_verbose() {
681        let path = std::path::PathBuf::from("/nonexistent/config.toml");
682        let result = diagnose(&path, None, Some("unsupported version: 99"), &DoctorEnvInfo::default(), |_| true);
683
684        let parse_check = result.checks.iter().find(|c| c.name == "config_parse").unwrap();
685        assert!(parse_check.detail_verbose.is_none(),
686            "detail_verbose must be None when error is single-line: {:?}", parse_check.detail_verbose);
687    }
688
689    #[test]
690    fn command_not_found_is_warn() {
691        let dir = tempfile::tempdir().unwrap();
692        let path = dir.path().join("config.toml");
693        std::fs::write(&path, "version = 1").unwrap();
694
695        let cfg = test_config(vec![abbr_when("ls", "lsd", vec!["lsd"])]);
696        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| false);
697
698        assert!(result.is_healthy());
699        assert_eq!(result.checks[2].status, CheckStatus::Warn);
700        assert!(result.checks[2].detail.contains("not found"));
701    }
702
703    #[test]
704    fn doctor_warns_empty_key() {
705        let path = std::path::PathBuf::from("/nonexistent/config.toml");
706        let cfg = test_config(vec![abbr("", "git commit -m")]);
707        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| true);
708        assert!(
709            result.checks.iter().any(|c| c.name.contains("empty_key") && c.status == CheckStatus::Warn),
710            "must warn on empty key: {:?}", result.checks
711        );
712    }
713
714    #[test]
715    fn doctor_warns_self_loop() {
716        let path = std::path::PathBuf::from("/nonexistent/config.toml");
717        let cfg = test_config(vec![abbr("ls", "ls")]);
718        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| true);
719        assert!(
720            result.checks.iter().any(|c| c.name.contains("self_loop") && c.status == CheckStatus::Warn),
721            "must warn on self-loop: {:?}", result.checks
722        );
723    }
724
725    #[test]
726    fn diag_result_is_healthy_with_error() {
727        let result = DiagResult {
728            checks: vec![Check {
729                name: "test".into(),
730                status: CheckStatus::Error,
731                detail: "bad".into(),
732                detail_verbose: None,
733            }],
734        };
735        assert!(!result.is_healthy());
736    }
737
738    #[test]
739    fn doctor_warns_shift_space_self_insert() {
740        let path = std::path::PathBuf::from("/nonexistent/config.toml");
741        let cfg = Config {
742            version: 1,
743            keybind: crate::model::KeybindConfig {
744                self_insert: crate::model::PerShellKey {
745                    bash: Some(crate::model::TriggerKey::ShiftSpace),
746                    ..Default::default()
747                },
748                ..crate::model::KeybindConfig::default()
749            },
750            precache: crate::model::PrecacheConfig::default(),
751            abbr: vec![],
752        };
753        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| true);
754        assert!(
755            result.checks.iter().any(|c| c.name == "keybind.self_insert" && c.status == CheckStatus::Warn),
756            "must warn when self_insert.bash = shift-space: {:?}", result.checks
757        );
758    }
759
760    #[test]
761    fn doctor_ok_alt_space_self_insert() {
762        let path = std::path::PathBuf::from("/nonexistent/config.toml");
763        let cfg = Config {
764            version: 1,
765            keybind: crate::model::KeybindConfig {
766                self_insert: crate::model::PerShellKey {
767                    pwsh: Some(crate::model::TriggerKey::ShiftSpace),
768                    ..Default::default()
769                },
770                ..crate::model::KeybindConfig::default()
771            },
772            precache: crate::model::PrecacheConfig::default(),
773            abbr: vec![],
774        };
775        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| true);
776        assert!(
777            !result.checks.iter().any(|c| c.name == "keybind.self_insert" && c.status == CheckStatus::Warn),
778            "must not warn when only self_insert.pwsh = shift-space: {:?}", result.checks
779        );
780    }
781
782    #[test]
783    fn doctor_warns_when_default_self_insert_is_shift_space() {
784        let path = std::path::PathBuf::from("/nonexistent/config.toml");
785        let cfg = Config {
786            version: 1,
787            keybind: crate::model::KeybindConfig {
788                self_insert: crate::model::PerShellKey {
789                    default: Some(crate::model::TriggerKey::ShiftSpace),
790                    ..Default::default()
791                },
792                ..crate::model::KeybindConfig::default()
793            },
794            precache: crate::model::PrecacheConfig::default(),
795            abbr: vec![],
796        };
797        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| true);
798        assert!(
799            result.checks.iter().any(|c| c.name == "keybind.self_insert" && c.status == CheckStatus::Warn),
800            "must warn when default self_insert = shift-space (propagates to bash/zsh): {:?}", result.checks
801        );
802    }
803
804    #[test]
805    fn doctor_ok_when_only_pwsh_self_insert_is_shift_space() {
806        let path = std::path::PathBuf::from("/nonexistent/config.toml");
807        let cfg = Config {
808            version: 1,
809            keybind: crate::model::KeybindConfig {
810                self_insert: crate::model::PerShellKey {
811                    pwsh: Some(crate::model::TriggerKey::ShiftSpace),
812                    ..Default::default()
813                },
814                ..crate::model::KeybindConfig::default()
815            },
816            precache: crate::model::PrecacheConfig::default(),
817            abbr: vec![],
818        };
819        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| true);
820        assert!(
821            !result.checks.iter().any(|c| c.name == "keybind.self_insert" && c.status == CheckStatus::Warn),
822            "must not warn when only pwsh self_insert = shift-space: {:?}", result.checks
823        );
824    }
825
826    } // mod diagnostics
827
828    /// Detail strings embed user-controlled values (keys, cmd names, config paths).
829    /// If these contain ANSI escape sequences or other control characters, they will
830    /// be printed raw to the terminal — enabling screen clearing, cursor movement,
831    /// or other terminal injection attacks. All detail and name fields must be sanitized.
832    mod sanitization {
833        use super::*;
834
835    /// A key containing a BEL (\x07) control character (valid TOML via \uXXXX escape)
836    /// must not appear raw in the detail string, which is printed to the terminal.
837    #[test]
838    fn doctor_self_loop_detail_strips_control_chars_from_key() {
839        let path = std::path::PathBuf::from("/nonexistent/config.toml");
840        let cfg = test_config(vec![abbr("key\x07evil", "key\x07evil")]);
841        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| true);
842        let self_loop = result.checks.iter().find(|c| c.name.contains("self_loop"));
843        let check = self_loop.expect("must produce a self_loop check for a self-loop key");
844        assert!(
845            !check.detail.contains('\x07'),
846            "detail must not contain raw control char BEL: {:?}", check.detail
847        );
848    }
849
850    /// A cmd in `when_command_exists` containing a control char must not appear raw in detail.
851    #[test]
852    fn doctor_command_check_detail_strips_control_chars_from_cmd() {
853        let path = std::path::PathBuf::from("/nonexistent/config.toml");
854        let cfg = test_config(vec![crate::model::Abbr {
855            key: "ls".into(),
856            expand: crate::model::PerShellString::All("lsd".into()),
857            when_command_exists: Some(crate::model::PerShellCmds::All(vec!["cmd\x07inject".into()])),
858        }]);
859        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| false);
860        let cmd_check = result.checks.iter().find(|c| c.name.contains("command:"));
861        let check = cmd_check.expect("must produce a command check");
862        assert!(
863            !check.detail.contains('\x07'),
864            "detail must not contain raw control char from cmd: {:?}", check.detail
865        );
866    }
867
868    /// `--config` path containing ANSI escape sequences must not appear raw in
869    /// the `config_file` check detail. Attack: a path with ESC sequences could clear the screen.
870    #[test]
871    fn doctor_config_file_detail_strips_control_chars_from_path() {
872        let path = std::path::PathBuf::from("/home/user/\x1b[2Jevil.toml");
873        let result = diagnose(&path, None, None, &DoctorEnvInfo::default(), |_| true);
874        let config_check = result.checks.iter().find(|c| c.name == "config_file");
875        let check = config_check.expect("must produce a config_file check");
876        assert!(
877            !check.detail.contains('\x1b'),
878            "config_file detail must not contain raw ESC from path: {:?}", check.detail
879        );
880    }
881
882    /// The `name` field `"command:{cmd}"` is printed to the terminal.
883    /// A cmd containing ANSI escape sequences (e.g. ESC+`[2J` = clear screen) must
884    /// not appear raw in `check.name` — sanitized the same way as `detail`.
885    #[test]
886    fn doctor_command_check_name_strips_control_chars() {
887        let path = std::path::PathBuf::from("/nonexistent/config.toml");
888        let cfg = test_config(vec![crate::model::Abbr {
889            key: "ls".into(),
890            expand: crate::model::PerShellString::All("lsd".into()),
891            when_command_exists: Some(crate::model::PerShellCmds::All(vec!["cmd\x1b[2Jevil".into()])),
892        }]);
893        let result = diagnose(&path, Some(&cfg), None, &DoctorEnvInfo::default(), |_| false);
894        let cmd_check = result.checks.iter().find(|c| c.name.starts_with("command:"));
895        let check = cmd_check.expect("must produce a command check");
896        assert!(
897            !check.name.contains('\x1b'),
898            "check.name must not contain raw ESC (ANSI injection risk): {:?}", check.name
899        );
900    }
901
902    } // mod sanitization
903
904    mod strict {
905        use super::*;
906
907    #[test]
908    fn check_unknown_top_level_field() {
909        let toml = r#"
910version = 1
911abr = "typo"
912"#;
913        let checks = check_unknown_fields(toml);
914        assert!(
915            checks.iter().any(|c| c.detail.contains("abr") && c.detail.contains("did you mean 'abbr'")),
916            "must detect 'abr' typo: {:?}", checks
917        );
918    }
919
920    #[test]
921    fn check_unknown_abbr_field() {
922        let toml = r#"
923version = 1
924[[abbr]]
925key = "gcm"
926expad = "git commit -m"
927"#;
928        let checks = check_unknown_fields(toml);
929        assert!(
930            checks.iter().any(|c| c.detail.contains("expad") && c.detail.contains("did you mean 'expand'")),
931            "must detect 'expad' typo: {:?}", checks
932        );
933    }
934
935    #[test]
936    fn check_no_warnings_for_valid_config() {
937        let toml = r#"
938version = 1
939[keybind.trigger]
940default = "space"
941[[abbr]]
942key = "gcm"
943expand = "git commit -m"
944when_command_exists = ["git"]
945"#;
946        let checks = check_unknown_fields(toml);
947        assert!(checks.is_empty(), "valid config must produce no warnings: {:?}", checks);
948    }
949
950    #[test]
951    fn precache_deprecation_warns_when_section_is_present() {
952        let toml = r#"
953version = 1
954[precache]
955path_only = true
956"#;
957        let checks = check_precache_deprecation(toml);
958        assert_eq!(checks.len(), 1, "should warn once when [precache] is present: {:?}", checks);
959        assert_eq!(checks[0].status, CheckStatus::Warn);
960        assert!(checks[0].detail.contains("deprecated"), "detail must say deprecated: {}", checks[0].detail);
961        assert_eq!(checks[0].name, "precache_deprecation");
962    }
963
964    #[test]
965    fn precache_deprecation_silent_when_section_absent() {
966        let toml = r#"
967version = 1
968[[abbr]]
969key = "gcm"
970expand = "git commit -m"
971"#;
972        let checks = check_precache_deprecation(toml);
973        assert!(checks.is_empty(), "no warning when [precache] is absent: {:?}", checks);
974    }
975
976    #[test]
977    fn precache_deprecation_silent_when_toml_invalid() {
978        // If the TOML doesn't even parse, check_config_parse handles it.
979        let checks = check_precache_deprecation("this is not [valid toml");
980        assert!(checks.is_empty());
981    }
982
983    #[test]
984    fn check_unknown_keybind_field() {
985        let toml = r#"
986version = 1
987[keybind]
988trigerr = "space"
989"#;
990        let checks = check_unknown_fields(toml);
991        assert!(
992            checks.iter().any(|c| c.detail.contains("trigerr") && c.detail.contains("did you mean 'trigger'")),
993            "must detect 'trigerr' typo: {:?}", checks
994        );
995    }
996
997    #[test]
998    fn suggest_similar_field_name() {
999        assert_eq!(suggest_similar("abr", KNOWN_TOP_LEVEL_KEYS), Some("abbr".to_string()));
1000        assert_eq!(suggest_similar("expad", KNOWN_ABBR_KEYS), Some("expand".to_string()));
1001        assert_eq!(suggest_similar("xyz_completely_different", KNOWN_TOP_LEVEL_KEYS), None);
1002    }
1003
1004    #[test]
1005    fn levenshtein_basic() {
1006        assert_eq!(levenshtein("", ""), 0);
1007        assert_eq!(levenshtein("abc", "abc"), 0);
1008        assert_eq!(levenshtein("abc", "abd"), 1);
1009        assert_eq!(levenshtein("abr", "abbr"), 1);
1010        assert_eq!(levenshtein("expad", "expand"), 1);
1011    }
1012
1013    #[test]
1014    fn check_duplicate_key_without_condition() {
1015        let cfg = test_config(vec![
1016            abbr("gcm", "git commit -m"),
1017            abbr("gcm", "git checkout main"),
1018        ]);
1019        let checks = check_unreachable_duplicates(&cfg);
1020        assert_eq!(checks.len(), 1);
1021        assert!(checks[0].detail.contains("gcm"), "must mention the key: {:?}", checks[0].detail);
1022        assert!(checks[0].detail.contains("unreachable"), "must say unreachable: {:?}", checks[0].detail);
1023    }
1024
1025    #[test]
1026    fn check_duplicate_key_with_condition_is_ok() {
1027        let cfg = test_config(vec![
1028            abbr_when("ls", "lsd", vec!["lsd"]),
1029            abbr("ls", "ls --color=auto"),
1030        ]);
1031        let checks = check_unreachable_duplicates(&cfg);
1032        assert!(checks.is_empty(), "fallback chain should not warn: {:?}", checks);
1033    }
1034
1035    #[test]
1036    fn check_duplicate_key_condition_then_no_condition_is_ok() {
1037        let cfg = test_config(vec![
1038            abbr_when("ls", "lsd", vec!["lsd"]),
1039            abbr_when("ls", "eza", vec!["eza"]),
1040            abbr("ls", "ls --color=auto"),
1041        ]);
1042        let checks = check_unreachable_duplicates(&cfg);
1043        assert!(checks.is_empty(), "all-conditional + one fallback should not warn: {:?}", checks);
1044    }
1045
1046    #[test]
1047    fn check_no_condition_blocks_later_rules() {
1048        let cfg = test_config(vec![
1049            abbr("gcm", "git commit -m"),       // unconditional — always matches
1050            abbr_when("gcm", "git cm", vec!["git"]),  // unreachable
1051        ]);
1052        let checks = check_unreachable_duplicates(&cfg);
1053        assert_eq!(checks.len(), 1);
1054        assert!(checks[0].detail.contains("#2"), "must mention the rule number: {:?}", checks[0].detail);
1055    }
1056
1057    } // mod strict
1058
1059    mod rejected_rules {
1060        use super::*;
1061
1062        #[test]
1063        fn check_rejected_rules_empty_for_valid_config() {
1064            let toml = r#"
1065version = 1
1066[[abbr]]
1067key = "gcm"
1068expand = "git commit -m"
1069"#;
1070            assert!(check_rejected_rules(toml).is_empty());
1071        }
1072
1073        #[test]
1074        fn check_rejected_rules_emits_summary_check_first() {
1075            let toml = r#"
1076version = 1
1077[[abbr]]
1078key = ""
1079expand = "x"
1080[[abbr]]
1081key = "ls"
1082expand = ""
1083"#;
1084            let checks = check_rejected_rules(toml);
1085            assert!(!checks.is_empty());
1086            assert_eq!(checks[0].name, "config_rejected_rules");
1087            assert!(checks[0].detail.contains("2 invalid"), "summary count: {:?}", checks[0].detail);
1088            // Remaining are per-field warns, sorted by rule order.
1089            assert!(checks[1].name.starts_with("config_validation.abbr[1]."));
1090            assert!(checks[2].name.starts_with("config_validation.abbr[2]."));
1091        }
1092
1093        #[test]
1094        fn check_rejected_rules_warns_for_each_bad_rule() {
1095            let toml = r#"
1096version = 1
1097[[abbr]]
1098key = ""
1099expand = "something"
1100[[abbr]]
1101key = "lsa"
1102expand = ""
1103[[abbr]]
1104key = "valid"
1105expand = "echo ok"
1106when_command_exists = ["good", "bad&inject"]
1107"#;
1108            let checks = check_rejected_rules(toml);
1109            // 1 summary + 3 per-field = 4
1110            assert_eq!(checks.len(), 4, "expected 1 summary + 3 warns: {checks:?}");
1111            assert_eq!(checks[1].name, "config_validation.abbr[1].key");
1112            assert_eq!(checks[2].name, "config_validation.abbr[2].expand");
1113            assert_eq!(checks[3].name, "config_validation.abbr[3].when_command_exists[2]");
1114        }
1115
1116        #[test]
1117        fn check_rejected_rules_does_not_leak_raw_values() {
1118            // Key contains a BEL control character. The check must not echo it.
1119            let toml = "
1120version = 1
1121[[abbr]]
1122key = \"gc\\u0007m\"
1123expand = \"x\"
1124";
1125            let checks = check_rejected_rules(toml);
1126            assert!(!checks.is_empty());
1127            for check in &checks {
1128                assert!(
1129                    !check.detail.contains('\x07'),
1130                    "raw BEL must not appear in detail: {:?}",
1131                    check.detail
1132                );
1133                assert!(
1134                    !check.name.contains('\x07'),
1135                    "raw BEL must not appear in name: {:?}",
1136                    check.name
1137                );
1138            }
1139        }
1140
1141        #[test]
1142        fn check_rejected_rules_skips_when_deserialization_fails() {
1143            // Invalid TOML syntax — check_config_parse handles this.
1144            let toml = "this is = not [ valid toml";
1145            assert!(check_rejected_rules(toml).is_empty());
1146        }
1147
1148        #[test]
1149        fn check_rejected_rules_skips_when_unsupported_version() {
1150            let toml = r#"version = 99
1151[[abbr]]
1152key = ""
1153expand = "x"
1154"#;
1155            assert!(check_rejected_rules(toml).is_empty());
1156        }
1157    } // mod rejected_rules
1158}