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 #[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#[derive(Debug, Clone, Default)]
43pub struct DoctorEnvInfo {
44 pub effective_search_path: Option<EffectiveSearchPathSummary>,
50
51 pub clink_export_for_drift_check: Option<String>,
58
59 pub check_rcfile_markers: RcfileMarkerSelection,
63}
64
65#[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 pub fn all() -> Self {
81 Self { bash: true, zsh: true, pwsh: true, nu: true }
82 }
83}
84
85#[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
100fn 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 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 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 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
241fn 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
257fn 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
269const KNOWN_TOP_LEVEL_KEYS: &[&str] = &["version", "keybind", "precache", "abbr"];
271
272const KNOWN_ABBR_KEYS: &[&str] = &["key", "expand", "when_command_exists"];
274
275const KNOWN_KEYBIND_KEYS: &[&str] = &["trigger", "self_insert"];
277
278const KNOWN_KEYBIND_SUB_KEYS: &[&str] = &["default", "bash", "zsh", "pwsh", "nu"];
280
281const KNOWN_PRECACHE_KEYS: &[&str] = &["path_only"];
283
284pub fn check_rejected_rules(config_source: &str) -> Vec<Check> {
297 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 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
346pub 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![], };
374
375 let mut checks = Vec::new();
376
377 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 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 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 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 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
478pub fn check_unreachable_duplicates(config: &Config) -> Vec<Check> {
484 let mut checks = Vec::new();
485 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 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 unconditional_keys.insert(&abbr.key, i);
505 }
506 }
507 checks
508}
509
510pub 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 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
550fn 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
565fn 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); assert_eq!(result.checks[1].status, CheckStatus::Ok); assert_eq!(result.checks[2].status, CheckStatus::Ok); }
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 sanitization {
833 use super::*;
834
835 #[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 #[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 #[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 #[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 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 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"), abbr_when("gcm", "git cm", vec!["git"]), ]);
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 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 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 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 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 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 } }