Skip to main content

runex_core/
config.rs

1use std::path::PathBuf;
2
3use crate::model::Config;
4use crate::sanitize::is_deceptive_unicode;
5
6const MAX_CONFIG_FILE_BYTES: u64 = 10 * 1024 * 1024; // 10 MB
7const MAX_ABBR_RULES: usize = 10_000;
8const MAX_KEY_BYTES: usize = 1_024;
9const MAX_EXPAND_BYTES: usize = 4_096;
10const MAX_CMD_BYTES: usize = 255;
11const MAX_CMD_LIST_LEN: usize = 64;
12
13#[derive(Debug, thiserror::Error)]
14pub enum ConfigError {
15    #[error("{0}")]
16    Parse(#[from] toml::de::Error),
17    #[error("IO error: {0}")]
18    Io(#[from] std::io::Error),
19    #[error("cannot determine config directory")]
20    NoConfigDir,
21    #[error("config file exceeds maximum size of 10 MB")]
22    FileTooLarge,
23    #[error("config has too many abbr rules (max {MAX_ABBR_RULES})")]
24    TooManyRules,
25    #[error("abbr rule #{0}: key exceeds maximum length of {MAX_KEY_BYTES} bytes")]
26    KeyTooLong(usize),
27    #[error("abbr rule #{0}: expand exceeds maximum length of {MAX_EXPAND_BYTES} bytes")]
28    ExpandTooLong(usize),
29    #[error("abbr rule #{0}: key contains a NUL byte")]
30    KeyContainsNul(usize),
31    #[error("abbr rule #{0}: expand contains a NUL byte")]
32    ExpandContainsNul(usize),
33    #[error("abbr rule #{0}: when_command_exists entry exceeds maximum length of {MAX_CMD_BYTES} bytes")]
34    CmdTooLong(usize),
35    #[error("abbr rule #{0}: when_command_exists entry contains a NUL byte")]
36    CmdContainsNul(usize),
37    #[error("abbr rule #{0}: when_command_exists entry contains an ASCII control character (use printable characters only)")]
38    CmdContainsControlChar(usize),
39    #[error("abbr rule #{0}: key contains an ASCII control character (use printable characters only)")]
40    KeyContainsControlChar(usize),
41    #[error("abbr rule #{0}: expand contains an ASCII control character (use printable characters only)")]
42    ExpandContainsControlChar(usize),
43    #[error("abbr rule #{0}: key is empty (an empty key can never match anything)")]
44    KeyEmpty(usize),
45    #[error("abbr rule #{0}: key contains only whitespace (a whitespace-only key can never match)")]
46    KeyWhitespaceOnly(usize),
47    #[error("abbr rule #{0}: when_command_exists entry is empty (an empty command name can never be found)")]
48    CmdEmpty(usize),
49    #[error("abbr rule #{0}: when_command_exists entry contains only whitespace (a whitespace-only command name can never be found)")]
50    CmdWhitespaceOnly(usize),
51    #[error("abbr rule #{0}: key contains a Unicode visual-deception character (invisible/directional char that makes the key unmatchable or misleading)")]
52    KeyContainsDeceptiveUnicode(usize),
53    #[error("abbr rule #{0}: expand contains a Unicode visual-deception character (invisible/directional char that makes the expansion misleading)")]
54    ExpandContainsDeceptiveUnicode(usize),
55    #[error("abbr rule #{0}: when_command_exists entry contains a Unicode visual-deception character")]
56    CmdContainsDeceptiveUnicode(usize),
57    #[error("abbr rule #{0}: when_command_exists entry contains a path separator ('/', '\\\\', or ':'); only bare command names are allowed")]
58    CmdContainsPathSeparator(usize),
59    #[error("abbr rule #{0}: when_command_exists entry contains a shell metacharacter or glob pattern; only bare command names are allowed")]
60    CmdContainsMetacharacter(usize),
61    #[error("abbr rule #{0}: when_command_exists has too many entries (max {MAX_CMD_LIST_LEN})")]
62    TooManyCmds(usize),
63    #[error("unsupported config version {0}; only version 1 is supported")]
64    UnsupportedVersion(u32),
65    #[error("abbr rule #{0}: expand is empty (an empty expansion would silently delete the typed token)")]
66    ExpandEmpty(usize),
67    #[error("abbr rule #{0}: expand contains only whitespace (a whitespace-only expansion is almost certainly a config mistake)")]
68    ExpandWhitespaceOnly(usize),
69}
70
71/// Reason a validation check failed. Shared across the walker (for doctor
72/// diagnostics) and the first-error adapter (for `parse_config`).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub(crate) enum ValidationReason {
75    // config-scope
76    TooManyRules,
77
78    // key (rule-scope)
79    KeyEmpty,
80    KeyWhitespaceOnly,
81    KeyTooLong,
82    KeyContainsNul,
83    KeyContainsControlChar,
84    KeyContainsDeceptiveUnicode,
85
86    // expand (rule-scope)
87    ExpandEmpty,
88    ExpandWhitespaceOnly,
89    ExpandTooLong,
90    ExpandContainsNul,
91    ExpandContainsControlChar,
92    ExpandContainsDeceptiveUnicode,
93
94    // when_command_exists list-level (rule-scope)
95    TooManyCmds,
96
97    // when_command_exists entry (rule-scope)
98    CmdEmpty,
99    CmdWhitespaceOnly,
100    CmdTooLong,
101    CmdContainsNul,
102    CmdContainsControlChar,
103    CmdContainsDeceptiveUnicode,
104    CmdContainsPathSeparator,
105    CmdContainsMetacharacter,
106}
107
108/// A single validation failure.
109///
110/// `ValidationIssue` carries enough information for `doctor` to produce a
111/// field-path-aware warning ("abbr[3].expand.pwsh rejected: expand is empty")
112/// while `parse_config` can still convert it back into a single `ConfigError`.
113///
114/// `field_path` is a *logical* path — it mirrors the in-memory `PerShellString`
115/// / `PerShellCmds` shape, not the literal TOML syntax. Array indices inside
116/// `field_path` are 1-based (consistent with `rule_index`).
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub(crate) enum ValidationIssue {
119    Config {
120        reason: ValidationReason,
121    },
122    Rule {
123        rule_index: usize,
124        field_path: String,
125        reason: ValidationReason,
126    },
127}
128
129impl ValidationIssue {
130    /// Human-readable reason phrase (no leading "abbr rule #N: " prefix).
131    /// Used by `doctor` for WARN detail text.
132    pub(crate) fn reason_text(&self) -> &'static str {
133        let reason = match self {
134            ValidationIssue::Config { reason } => reason,
135            ValidationIssue::Rule { reason, .. } => reason,
136        };
137        match reason {
138            ValidationReason::TooManyRules => "config has too many abbr rules",
139            ValidationReason::KeyEmpty => "key is empty",
140            ValidationReason::KeyWhitespaceOnly => "key contains only whitespace",
141            ValidationReason::KeyTooLong => "key exceeds the maximum length",
142            ValidationReason::KeyContainsNul => "key contains a NUL byte",
143            ValidationReason::KeyContainsControlChar => "key contains an ASCII control character",
144            ValidationReason::KeyContainsDeceptiveUnicode => "key contains a Unicode visual-deception character",
145            ValidationReason::ExpandEmpty => "expand is empty",
146            ValidationReason::ExpandWhitespaceOnly => "expand contains only whitespace",
147            ValidationReason::ExpandTooLong => "expand exceeds the maximum length",
148            ValidationReason::ExpandContainsNul => "expand contains a NUL byte",
149            ValidationReason::ExpandContainsControlChar => "expand contains an ASCII control character",
150            ValidationReason::ExpandContainsDeceptiveUnicode => "expand contains a Unicode visual-deception character",
151            ValidationReason::TooManyCmds => "when_command_exists has too many entries",
152            ValidationReason::CmdEmpty => "when_command_exists entry is empty",
153            ValidationReason::CmdWhitespaceOnly => "when_command_exists entry contains only whitespace",
154            ValidationReason::CmdTooLong => "when_command_exists entry exceeds the maximum length",
155            ValidationReason::CmdContainsNul => "when_command_exists entry contains a NUL byte",
156            ValidationReason::CmdContainsControlChar => "when_command_exists entry contains an ASCII control character",
157            ValidationReason::CmdContainsDeceptiveUnicode => "when_command_exists entry contains a Unicode visual-deception character",
158            ValidationReason::CmdContainsPathSeparator => "when_command_exists entry contains a path separator",
159            ValidationReason::CmdContainsMetacharacter => "when_command_exists entry contains a shell metacharacter or glob pattern",
160        }
161    }
162
163    /// Convert back to a `ConfigError` for `parse_config`.
164    pub(crate) fn to_config_error(&self) -> ConfigError {
165        let (reason, n_for_rule) = match self {
166            ValidationIssue::Config { reason } => (reason, 0usize),
167            ValidationIssue::Rule { reason, rule_index, .. } => (reason, *rule_index),
168        };
169        let n = n_for_rule;
170        match reason {
171            ValidationReason::TooManyRules => ConfigError::TooManyRules,
172            ValidationReason::KeyEmpty => ConfigError::KeyEmpty(n),
173            ValidationReason::KeyWhitespaceOnly => ConfigError::KeyWhitespaceOnly(n),
174            ValidationReason::KeyTooLong => ConfigError::KeyTooLong(n),
175            ValidationReason::KeyContainsNul => ConfigError::KeyContainsNul(n),
176            ValidationReason::KeyContainsControlChar => ConfigError::KeyContainsControlChar(n),
177            ValidationReason::KeyContainsDeceptiveUnicode => ConfigError::KeyContainsDeceptiveUnicode(n),
178            ValidationReason::ExpandEmpty => ConfigError::ExpandEmpty(n),
179            ValidationReason::ExpandWhitespaceOnly => ConfigError::ExpandWhitespaceOnly(n),
180            ValidationReason::ExpandTooLong => ConfigError::ExpandTooLong(n),
181            ValidationReason::ExpandContainsNul => ConfigError::ExpandContainsNul(n),
182            ValidationReason::ExpandContainsControlChar => ConfigError::ExpandContainsControlChar(n),
183            ValidationReason::ExpandContainsDeceptiveUnicode => ConfigError::ExpandContainsDeceptiveUnicode(n),
184            ValidationReason::TooManyCmds => ConfigError::TooManyCmds(n),
185            ValidationReason::CmdEmpty => ConfigError::CmdEmpty(n),
186            ValidationReason::CmdWhitespaceOnly => ConfigError::CmdWhitespaceOnly(n),
187            ValidationReason::CmdTooLong => ConfigError::CmdTooLong(n),
188            ValidationReason::CmdContainsNul => ConfigError::CmdContainsNul(n),
189            ValidationReason::CmdContainsControlChar => ConfigError::CmdContainsControlChar(n),
190            ValidationReason::CmdContainsDeceptiveUnicode => ConfigError::CmdContainsDeceptiveUnicode(n),
191            ValidationReason::CmdContainsPathSeparator => ConfigError::CmdContainsPathSeparator(n),
192            ValidationReason::CmdContainsMetacharacter => ConfigError::CmdContainsMetacharacter(n),
193        }
194    }
195}
196
197/// Validate the `key` field of an abbreviation rule.
198///
199/// Rejects keys that are empty, whitespace-only, or exceed [`MAX_KEY_BYTES`].
200/// Also rejects keys containing NUL bytes, ASCII control characters, or Unicode
201/// visual-deception characters — all of which would make the key unmatchable or
202/// cause it to display differently from its actual byte content.
203fn check_abbr_key(key: &str) -> Option<ValidationReason> {
204    if key.is_empty() {
205        return Some(ValidationReason::KeyEmpty);
206    }
207    if key.trim().is_empty() {
208        return Some(ValidationReason::KeyWhitespaceOnly);
209    }
210    if key.len() > MAX_KEY_BYTES {
211        return Some(ValidationReason::KeyTooLong);
212    }
213    if key.contains('\0') {
214        return Some(ValidationReason::KeyContainsNul);
215    }
216    if key.chars().any(|c| c.is_ascii_control()) {
217        return Some(ValidationReason::KeyContainsControlChar);
218    }
219    if key.chars().any(is_deceptive_unicode) {
220        return Some(ValidationReason::KeyContainsDeceptiveUnicode);
221    }
222    None
223}
224
225/// Validate a single expand string value.
226fn check_expand_value(expand: &str) -> Option<ValidationReason> {
227    if expand.is_empty() {
228        return Some(ValidationReason::ExpandEmpty);
229    }
230    if expand.trim().is_empty() {
231        return Some(ValidationReason::ExpandWhitespaceOnly);
232    }
233    if expand.len() > MAX_EXPAND_BYTES {
234        return Some(ValidationReason::ExpandTooLong);
235    }
236    if expand.contains('\0') {
237        return Some(ValidationReason::ExpandContainsNul);
238    }
239    if expand.chars().any(|c| c.is_ascii_control()) {
240        return Some(ValidationReason::ExpandContainsControlChar);
241    }
242    if expand.chars().any(is_deceptive_unicode) {
243        return Some(ValidationReason::ExpandContainsDeceptiveUnicode);
244    }
245    None
246}
247
248/// Validate a single `when_command_exists` entry.
249///
250/// Rejects entries that are empty, whitespace-only, or exceed [`MAX_CMD_BYTES`].
251/// Also rejects entries containing NUL bytes, ASCII control characters, Unicode
252/// visual-deception characters, or path separators (`/`, `\`, `:`).
253/// Only bare command names are allowed — filesystem paths would bypass the intent
254/// of checking only within `path_prepend`.
255fn check_cmd_entry(cmd: &str) -> Option<ValidationReason> {
256    if cmd.is_empty() {
257        return Some(ValidationReason::CmdEmpty);
258    }
259    if cmd.trim().is_empty() {
260        return Some(ValidationReason::CmdWhitespaceOnly);
261    }
262    if cmd.len() > MAX_CMD_BYTES {
263        return Some(ValidationReason::CmdTooLong);
264    }
265    if cmd.contains('\0') {
266        return Some(ValidationReason::CmdContainsNul);
267    }
268    if cmd.chars().any(|c| c.is_ascii_control()) {
269        return Some(ValidationReason::CmdContainsControlChar);
270    }
271    if cmd.chars().any(is_deceptive_unicode) {
272        return Some(ValidationReason::CmdContainsDeceptiveUnicode);
273    }
274    if cmd.contains('/') || cmd.contains('\\') || cmd.contains(':') {
275        return Some(ValidationReason::CmdContainsPathSeparator);
276    }
277    // Reject shell metacharacters, cmd.exe metacharacters, glob patterns, and
278    // the `,`/`=` delimiters used by the precache resolved protocol.
279    // Only bare alphanumeric command names (plus `-`, `_`, `.`, `+`) are allowed.
280    const METACHARS: &[char] = &[
281        // POSIX shell
282        '&', '|', ';', '<', '>', '`', '$', '(', ')', '{', '}', '\'', '"',
283        // cmd.exe
284        '%', '^',
285        // Whitespace that breaks shell/cmd tokenization
286        ' ', '\t',
287        // Glob patterns (matched by Get-Command -Name in pwsh)
288        '*', '?', '[', ']',
289        // Precache resolved protocol delimiters
290        ',', '=',
291        // Other risky punctuation in some shells
292        '!', '#', '~',
293    ];
294    if cmd.chars().any(|c| METACHARS.contains(&c)) {
295        return Some(ValidationReason::CmdContainsMetacharacter);
296    }
297    None
298}
299
300/// Shell labels for per-shell variant field paths. Order matches
301/// `PerShellString::all_values` / `PerShellCmds::all_values`.
302const PER_SHELL_LABELS: &[&str] = &["default", "bash", "zsh", "pwsh", "nu"];
303
304/// Walk all rule-scope expand values for a `PerShellString`.
305/// Visits the same values in the same order as `PerShellString::all_values()`.
306fn walk_expand_issues(
307    expand: &crate::model::PerShellString,
308    rule_index: usize,
309    mut f: impl FnMut(ValidationIssue) -> std::ops::ControlFlow<()>,
310) -> std::ops::ControlFlow<()> {
311    use crate::model::PerShellString;
312    match expand {
313        PerShellString::All(s) => {
314            if let Some(reason) = check_expand_value(s) {
315                f(ValidationIssue::Rule {
316                    rule_index,
317                    field_path: "expand".into(),
318                    reason,
319                })?;
320            }
321        }
322        PerShellString::ByShell { default, bash, zsh, pwsh, nu } => {
323            let variants: [(&&str, &Option<String>); 5] = [
324                (&PER_SHELL_LABELS[0], default),
325                (&PER_SHELL_LABELS[1], bash),
326                (&PER_SHELL_LABELS[2], zsh),
327                (&PER_SHELL_LABELS[3], pwsh),
328                (&PER_SHELL_LABELS[4], nu),
329            ];
330            for (label, value) in variants {
331                if let Some(s) = value {
332                    if let Some(reason) = check_expand_value(s) {
333                        f(ValidationIssue::Rule {
334                            rule_index,
335                            field_path: format!("expand.{}", label),
336                            reason,
337                        })?;
338                    }
339                }
340            }
341        }
342    }
343    std::ops::ControlFlow::Continue(())
344}
345
346/// Walk `when_command_exists`, including the list-level `TooManyCmds` check
347/// before per-entry checks. Preserves `parse_config`'s ordering.
348fn walk_cmds_issues(
349    cmds: &crate::model::PerShellCmds,
350    rule_index: usize,
351    mut f: impl FnMut(ValidationIssue) -> std::ops::ControlFlow<()>,
352) -> std::ops::ControlFlow<()> {
353    use crate::model::PerShellCmds;
354
355    // Variant-level walk: always in [All] or [default, bash, zsh, pwsh, nu] order.
356    let variants: Vec<(Option<&str>, &[String])> = match cmds {
357        PerShellCmds::All(v) => vec![(None, v.as_slice())],
358        PerShellCmds::ByShell { default, bash, zsh, pwsh, nu } => {
359            let mut out = Vec::with_capacity(5);
360            for (label, value) in [
361                (PER_SHELL_LABELS[0], default),
362                (PER_SHELL_LABELS[1], bash),
363                (PER_SHELL_LABELS[2], zsh),
364                (PER_SHELL_LABELS[3], pwsh),
365                (PER_SHELL_LABELS[4], nu),
366            ] {
367                if let Some(v) = value {
368                    out.push((Some(label), v.as_slice()));
369                }
370            }
371            out
372        }
373    };
374
375    for (label, list) in variants {
376        // (list-level) TooManyCmds before per-entry validation.
377        if list.len() > MAX_CMD_LIST_LEN {
378            let path = match label {
379                Some(l) => format!("when_command_exists.{}", l),
380                None => "when_command_exists".into(),
381            };
382            f(ValidationIssue::Rule {
383                rule_index,
384                field_path: path,
385                reason: ValidationReason::TooManyCmds,
386            })?;
387            continue; // skip per-entry walk for an oversize list
388        }
389        // Per-entry walk
390        for (j, cmd) in list.iter().enumerate() {
391            if let Some(reason) = check_cmd_entry(cmd) {
392                let path = match label {
393                    Some(l) => format!("when_command_exists.{}[{}]", l, j + 1),
394                    None => format!("when_command_exists[{}]", j + 1),
395                };
396                f(ValidationIssue::Rule {
397                    rule_index,
398                    field_path: path,
399                    reason,
400                })?;
401            }
402        }
403    }
404    std::ops::ControlFlow::Continue(())
405}
406
407/// Visit every validation issue in the config in `parse_config` order.
408///
409/// The caller chooses `Continue` (collect all) or `Break` (stop at first).
410fn visit_validation_issues(
411    config: &Config,
412    mut f: impl FnMut(ValidationIssue) -> std::ops::ControlFlow<()>,
413) {
414    if config.abbr.len() > MAX_ABBR_RULES {
415        let _ = f(ValidationIssue::Config { reason: ValidationReason::TooManyRules });
416        return;
417    }
418    for (i, abbr) in config.abbr.iter().enumerate() {
419        let rule_index = i + 1;
420        // (b-1) key
421        if let Some(reason) = check_abbr_key(&abbr.key) {
422            if f(ValidationIssue::Rule {
423                rule_index,
424                field_path: "key".into(),
425                reason,
426            })
427            .is_break()
428            {
429                return;
430            }
431        }
432        // (b-2) expand (per-shell aware)
433        if walk_expand_issues(&abbr.expand, rule_index, &mut f).is_break() {
434            return;
435        }
436        // (b-3) when_command_exists (per-shell aware + list-level + per-entry)
437        if let Some(cmds) = &abbr.when_command_exists {
438            if walk_cmds_issues(cmds, rule_index, &mut f).is_break() {
439                return;
440            }
441        }
442    }
443}
444
445/// Collect every validation issue in the config (used by `doctor`).
446pub(crate) fn collect_validation_issues(config: &Config) -> Vec<ValidationIssue> {
447    let mut issues = Vec::new();
448    visit_validation_issues(config, |issue| {
449        issues.push(issue);
450        std::ops::ControlFlow::Continue(())
451    });
452    issues
453}
454
455/// Return the first validation error, preserving `parse_config` ordering.
456fn first_validation_error(config: &Config) -> Option<ConfigError> {
457    let mut first = None;
458    visit_validation_issues(config, |issue| {
459        first = Some(issue.to_config_error());
460        std::ops::ControlFlow::Break(())
461    });
462    first
463}
464
465/// Deserialize a TOML string to `Config` without running validation.
466/// Used by `doctor` to walk the config for issues even when `parse_config`
467/// would fail. Version check is still enforced.
468pub(crate) fn parse_config_lenient(s: &str) -> Result<Config, ConfigError> {
469    let config: Config = toml::from_str(s)?;
470    if config.version != 1 {
471        return Err(ConfigError::UnsupportedVersion(config.version));
472    }
473    Ok(config)
474}
475
476/// Parse a TOML string into a [`Config`].
477///
478/// Only version 1 is accepted. All abbreviation rules are validated via
479/// [`visit_validation_issues`]; the first violation is returned as a
480/// `ConfigError`. Validation order is pinned by the `visit_validation_issues`
481/// traversal order.
482pub fn parse_config(s: &str) -> Result<Config, ConfigError> {
483    let config = parse_config_lenient(s)?;
484    if let Some(e) = first_validation_error(&config) {
485        return Err(e);
486    }
487    Ok(config)
488}
489
490/// Default config file path: `$XDG_CONFIG_HOME/runex/config.toml`,
491/// falling back to `~/.config/runex/config.toml` when `XDG_CONFIG_HOME` is unset.
492/// All platforms use this same resolution order.
493/// Overridden by `RUNEX_CONFIG` env var.
494pub fn default_config_path() -> Result<PathBuf, ConfigError> {
495    if let Ok(p) = std::env::var("RUNEX_CONFIG") {
496        if !p.is_empty() {
497            return Ok(PathBuf::from(p));
498        }
499    }
500    let dir = xdg_config_home();
501    Ok(dir.ok_or(ConfigError::NoConfigDir)?.join("runex").join("config.toml"))
502}
503
504/// Resolve `$XDG_CONFIG_HOME`, falling back to `~/.config`.
505pub(crate) fn xdg_config_home() -> Option<PathBuf> {
506    if let Ok(p) = std::env::var("XDG_CONFIG_HOME") {
507        if !p.is_empty() {
508            return Some(PathBuf::from(p));
509        }
510    }
511    dirs::home_dir().map(|h| h.join(".config"))
512}
513
514/// Load config from a file path.
515///
516/// Opens the file once and uses the same file descriptor for both the size check
517/// and the read, eliminating the TOCTOU race that exists when `metadata()` and
518/// `read_to_string()` open the file separately.
519///
520/// On Unix, `O_NOFOLLOW` rejects symlinks at the final path component, and `O_NONBLOCK`
521/// prevents `open()` from blocking on a named pipe with no writer. Non-regular files
522/// (device nodes, FIFOs) can bypass the size guard by reporting `len() == 0`, so they
523/// are rejected via `is_file()` immediately after open.
524pub fn load_config(path: &std::path::Path) -> Result<Config, ConfigError> {
525    let content = read_config_source(path)?;
526    parse_config(&content)
527}
528
529/// Read a config file into a string with the same safety guarantees as [`load_config`]:
530/// single fd for metadata+read (no TOCTOU), rejects symlinks at final path component on
531/// Unix, rejects non-regular files (FIFO / device nodes), and enforces the 10 MB size cap.
532///
533/// Use this when you need the raw TOML source (e.g. for `doctor --strict` unknown-field
534/// detection). For normal config loading, call `load_config` which parses the result.
535pub fn read_config_source(path: &std::path::Path) -> Result<String, ConfigError> {
536    use std::io::Read;
537    #[cfg(unix)]
538    let mut file = {
539        use std::os::unix::fs::OpenOptionsExt;
540        let resolved = path.canonicalize()?;
541        std::fs::OpenOptions::new()
542            .read(true)
543            .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
544            .open(&resolved)?
545    };
546    #[cfg(not(unix))]
547    let mut file = std::fs::File::open(path)?;
548    let meta = file.metadata()?;
549    if !meta.is_file() {
550        return Err(ConfigError::Io(std::io::Error::new(
551            std::io::ErrorKind::InvalidInput,
552            "config path must be a regular file",
553        )));
554    }
555    if meta.len() > MAX_CONFIG_FILE_BYTES {
556        return Err(ConfigError::FileTooLarge);
557    }
558    let mut content = String::new();
559    file.read_to_string(&mut content)?;
560    Ok(content)
561}
562
563/// Open a config file for append/write, rejecting symlinks at the final path
564/// component on Unix. Prevents an attacker who controls the config directory
565/// from redirecting writes to a sensitive file via a swapped symlink.
566#[cfg(unix)]
567fn open_config_for_append_safely(path: &std::path::Path) -> std::io::Result<std::fs::File> {
568    use std::os::unix::fs::OpenOptionsExt;
569    std::fs::OpenOptions::new()
570        .create(true)
571        .append(true)
572        .custom_flags(libc::O_NOFOLLOW)
573        .open(path)
574}
575
576#[cfg(not(unix))]
577fn open_config_for_append_safely(path: &std::path::Path) -> std::io::Result<std::fs::File> {
578    // Windows has no portable O_NOFOLLOW equivalent at open() time; rely on
579    // NTFS permissions at the config dir level.
580    std::fs::OpenOptions::new().create(true).append(true).open(path)
581}
582
583/// Atomically replace a config file: write to a sibling temp file then rename.
584/// On Unix the temp file is created with O_NOFOLLOW so a pre-existing symlink
585/// at the temp path cannot redirect the write.
586fn atomically_write_config(path: &std::path::Path, contents: &str) -> Result<(), ConfigError> {
587    use std::io::Write;
588    let parent = path.parent().ok_or_else(|| {
589        ConfigError::Io(std::io::Error::new(
590            std::io::ErrorKind::InvalidInput,
591            "config path has no parent directory",
592        ))
593    })?;
594    let file_name = path
595        .file_name()
596        .and_then(|n| n.to_str())
597        .ok_or_else(|| {
598            ConfigError::Io(std::io::Error::new(
599                std::io::ErrorKind::InvalidInput,
600                "config path has no file name",
601            ))
602        })?;
603    let tmp = parent.join(format!(".{file_name}.runex.tmp"));
604
605    // Best-effort cleanup of a stale temp file from a previous crash.
606    let _ = std::fs::remove_file(&tmp);
607
608    #[cfg(unix)]
609    let mut file = {
610        use std::os::unix::fs::OpenOptionsExt;
611        std::fs::OpenOptions::new()
612            .create_new(true)
613            .write(true)
614            .custom_flags(libc::O_NOFOLLOW)
615            .open(&tmp)
616            .map_err(ConfigError::Io)?
617    };
618    #[cfg(not(unix))]
619    let mut file = std::fs::OpenOptions::new()
620        .create_new(true)
621        .write(true)
622        .open(&tmp)
623        .map_err(ConfigError::Io)?;
624
625    file.write_all(contents.as_bytes()).map_err(ConfigError::Io)?;
626    file.sync_all().map_err(ConfigError::Io)?;
627    drop(file);
628
629    std::fs::rename(&tmp, path).map_err(|e| {
630        let _ = std::fs::remove_file(&tmp);
631        ConfigError::Io(e)
632    })
633}
634
635/// Append an abbreviation rule to a config file.
636///
637/// Appends a `[[abbr]]` block at the end of the file, preserving existing
638/// content and formatting. Validates the new rule before appending. Rejects
639/// symlinks at the final path component on Unix.
640pub fn append_abbr_to_file(
641    path: &std::path::Path,
642    key: &str,
643    expand: &str,
644    when_command_exists: Option<&[String]>,
645) -> Result<(), ConfigError> {
646    let n = 0; // validation uses 1-indexed rule numbers, but we use 0 for "new rule"
647    if let Some(reason) = check_abbr_key(key) {
648        return Err(ValidationIssue::Rule { rule_index: n, field_path: "key".into(), reason }.to_config_error());
649    }
650    if let Some(reason) = check_expand_value(expand) {
651        return Err(ValidationIssue::Rule { rule_index: n, field_path: "expand".into(), reason }.to_config_error());
652    }
653    if let Some(cmds) = when_command_exists {
654        for cmd in cmds {
655            if let Some(reason) = check_cmd_entry(cmd) {
656                return Err(ValidationIssue::Rule { rule_index: n, field_path: "when_command_exists".into(), reason }.to_config_error());
657            }
658        }
659    }
660
661    let mut block = String::from("\n[[abbr]]\n");
662    block.push_str(&format!("key = {}\n", toml_quote(key)));
663    block.push_str(&format!("expand = {}\n", toml_quote(expand)));
664    if let Some(cmds) = when_command_exists {
665        let quoted: Vec<String> = cmds.iter().map(|c| toml_quote(c)).collect();
666        block.push_str(&format!("when_command_exists = [{}]\n", quoted.join(", ")));
667    }
668
669    use std::io::Write;
670    let mut file = open_config_for_append_safely(path).map_err(ConfigError::Io)?;
671    file.write_all(block.as_bytes()).map_err(ConfigError::Io)?;
672    Ok(())
673}
674
675/// Remove all abbreviation rules with the given key from a config file.
676///
677/// Uses `toml_edit` to parse and edit the file while preserving formatting.
678/// Writes atomically via a sibling temp file + rename. Returns the number of
679/// rules removed.
680pub fn remove_abbr_from_file(path: &std::path::Path, key: &str) -> Result<usize, ConfigError> {
681    let content = read_config_source(path)?;
682    let mut doc = content.parse::<toml_edit::DocumentMut>().map_err(|_| {
683        ConfigError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, "failed to parse config as editable TOML"))
684    })?;
685
686    let removed = if let Some(toml_edit::Item::ArrayOfTables(arr)) = doc.get_mut("abbr") {
687        let before = arr.len();
688        let mut i = 0;
689        while i < arr.len() {
690            let matches = arr.get(i)
691                .and_then(|t| t.get("key"))
692                .and_then(|v| v.as_str())
693                .map(|k| k == key)
694                .unwrap_or(false);
695            if matches {
696                arr.remove(i);
697            } else {
698                i += 1;
699            }
700        }
701        before - arr.len()
702    } else {
703        0
704    };
705
706    if removed > 0 {
707        atomically_write_config(path, &doc.to_string())?;
708    }
709    Ok(removed)
710}
711
712/// Quote a string value for TOML output.
713fn toml_quote(s: &str) -> String {
714    // Use basic string with escaping for control chars
715    let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
716    format!("\"{}\"", escaped)
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::model::TriggerKey;
723    use serial_test::serial;
724
725    mod parsing {
726        use super::*;
727
728    #[test]
729    fn parse_minimal_toml() {
730        let toml = r#"
731version = 1
732
733[[abbr]]
734key = "gcm"
735expand = "git commit -m"
736"#;
737        let config = parse_config(toml).unwrap();
738        assert_eq!(config.version, 1);
739        assert_eq!(config.abbr.len(), 1);
740        assert_eq!(config.abbr[0].key, "gcm");
741        assert_eq!(config.abbr[0].expand, crate::model::PerShellString::All("git commit -m".into()));
742    }
743
744    #[test]
745    fn parse_with_when_command_exists() {
746        let toml = r#"
747version = 1
748
749[[abbr]]
750key = "ls"
751expand = "lsd"
752when_command_exists = ["lsd"]
753"#;
754        let config = parse_config(toml).unwrap();
755        assert_eq!(
756            config.abbr[0].when_command_exists,
757            Some(crate::model::PerShellCmds::All(vec!["lsd".to_string()]))
758        );
759    }
760
761    #[test]
762    fn parse_with_keybind() {
763        let toml = r#"
764version = 1
765
766[keybind.trigger]
767default = "space"
768bash = "alt-space"
769zsh = "space"
770pwsh = "tab"
771"#;
772        let config = parse_config(toml).unwrap();
773        assert_eq!(config.keybind.trigger.default, Some(TriggerKey::Space));
774        assert_eq!(config.keybind.trigger.bash, Some(TriggerKey::AltSpace));
775        assert_eq!(config.keybind.trigger.zsh, Some(TriggerKey::Space));
776        assert_eq!(config.keybind.trigger.pwsh, Some(TriggerKey::Tab));
777        assert_eq!(config.keybind.trigger.nu, None);
778    }
779
780    #[test]
781    fn parse_config_with_subtable_trigger() {
782        let toml = r#"
783version = 1
784
785[keybind.trigger]
786default = "space"
787bash = "alt-space"
788pwsh = "tab"
789
790[keybind.self_insert]
791pwsh = "shift-space"
792nu   = "shift-space"
793"#;
794        let config = parse_config(toml).unwrap();
795        assert_eq!(config.keybind.trigger.default, Some(TriggerKey::Space));
796        assert_eq!(config.keybind.trigger.bash, Some(TriggerKey::AltSpace));
797        assert_eq!(config.keybind.trigger.pwsh, Some(TriggerKey::Tab));
798        assert_eq!(config.keybind.trigger.zsh, None);
799        assert_eq!(config.keybind.self_insert.pwsh, Some(TriggerKey::ShiftSpace));
800        assert_eq!(config.keybind.self_insert.nu, Some(TriggerKey::ShiftSpace));
801        assert_eq!(config.keybind.self_insert.bash, None);
802    }
803
804    #[test]
805    fn parse_config_keybind_absent_gives_all_none() {
806        let toml = "version = 1\n";
807        let config = parse_config(toml).unwrap();
808        assert_eq!(config.keybind.trigger.default, None);
809        assert_eq!(config.keybind.trigger.bash, None);
810        assert_eq!(config.keybind.self_insert.pwsh, None);
811    }
812
813    /// TOML allows any string for `trigger`, but only known variants are valid.
814    /// An unknown value must be rejected so the user gets an explicit error rather than
815    /// silently falling back to a default they didn't request.
816    #[test]
817    fn parse_config_rejects_invalid_trigger_key() {
818        let toml = "version = 1\n[keybind.trigger]\ndefault = \"invalid-key\"\n";
819        assert!(
820            parse_config(toml).is_err(),
821            "must reject unknown trigger key value 'invalid-key'"
822        );
823    }
824
825    #[test]
826    fn parse_config_rejects_invalid_per_shell_keybind() {
827        for field in ["bash", "zsh", "pwsh", "nu"] {
828            let toml = format!("version = 1\n[keybind.trigger]\n{field} = \"unknown-keybind\"\n");
829            assert!(
830                parse_config(&toml).is_err(),
831                "must reject unknown keybind value for field '{field}'"
832            );
833        }
834    }
835
836    #[test]
837    fn parse_missing_version_is_err() {
838        let toml = r#"
839[[abbr]]
840key = "gcm"
841expand = "git commit -m"
842"#;
843        assert!(parse_config(toml).is_err());
844    }
845
846    #[test]
847    fn parse_empty_abbr_list() {
848        let toml = "version = 1\n";
849        let config = parse_config(toml).unwrap();
850        assert!(config.abbr.is_empty());
851    }
852
853    #[test]
854    fn load_config_from_file() {
855        let dir = std::env::temp_dir().join("runex_test_load");
856        std::fs::create_dir_all(&dir).unwrap();
857        let path = dir.join("config.toml");
858        std::fs::write(
859            &path,
860            r#"
861version = 1
862
863[[abbr]]
864key = "gcm"
865expand = "git commit -m"
866"#,
867        )
868        .unwrap();
869
870        let config = load_config(&path).unwrap();
871        assert_eq!(config.version, 1);
872        assert_eq!(config.abbr[0].key, "gcm");
873
874        std::fs::remove_dir_all(&dir).ok();
875    }
876
877    /// Safety: env mutation is serialized via `#[serial]`; no concurrent
878    /// env access within this test suite. External concurrent access is
879    /// not fully excluded but acceptable in test context.
880    #[test]
881    #[serial]
882    fn default_config_path_env_override() {
883        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
884        unsafe { std::env::set_var("RUNEX_CONFIG", "/tmp/custom.toml") };
885        let path = default_config_path().unwrap();
886        unsafe { std::env::remove_var("RUNEX_CONFIG") };
887        assert_eq!(path, PathBuf::from("/tmp/custom.toml"));
888    }
889
890    /// Safety: see `default_config_path_env_override`.
891    #[test]
892    #[serial]
893    fn xdg_config_home_uses_env_var() {
894        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-test") };
895        let dir = xdg_config_home().unwrap();
896        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
897        assert_eq!(dir, PathBuf::from("/tmp/xdg-test"));
898    }
899
900    /// Safety: see `default_config_path_env_override`.
901    #[test]
902    #[serial]
903    fn xdg_config_home_empty_env_falls_back_to_home() {
904        unsafe { std::env::set_var("XDG_CONFIG_HOME", "") };
905        let dir = xdg_config_home().unwrap();
906        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
907        assert!(dir.ends_with(".config"), "expected ~/.config fallback, got {dir:?}");
908    }
909
910    /// Safety: see `default_config_path_env_override`.
911    #[test]
912    #[serial]
913    fn default_config_path_uses_xdg_config_home() {
914        unsafe { std::env::remove_var("RUNEX_CONFIG") };
915        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-runex-test") };
916        let path = default_config_path().unwrap();
917        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
918        assert_eq!(path, PathBuf::from("/tmp/xdg-runex-test/runex/config.toml"));
919    }
920
921    /// Safety: see `default_config_path_env_override`.
922    #[test]
923    #[serial]
924    fn default_config_path_ignores_empty_runex_config() {
925        unsafe { std::env::set_var("RUNEX_CONFIG", "") };
926        unsafe { std::env::set_var("XDG_CONFIG_HOME", "/tmp/xdg-empty-test") };
927        let path = default_config_path().unwrap();
928        unsafe { std::env::remove_var("RUNEX_CONFIG") };
929        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
930        assert_eq!(
931            path,
932            PathBuf::from("/tmp/xdg-empty-test/runex/config.toml"),
933            "empty RUNEX_CONFIG must fall through to XDG resolution"
934        );
935    }
936
937    #[test]
938    fn parse_config_rejects_too_many_abbr() {
939        let mut s = String::from("version = 1\n");
940        for i in 0..10_001 {
941            s.push_str(&format!("[[abbr]]\nkey = \"k{i}\"\nexpand = \"v{i}\"\n"));
942        }
943        assert!(parse_config(&s).is_err(), "must reject configs with more than 10,000 abbr rules");
944    }
945
946    #[test]
947    fn parse_config_accepts_max_abbr() {
948        let mut s = String::from("version = 1\n");
949        for i in 0..10_000 {
950            s.push_str(&format!("[[abbr]]\nkey = \"k{i}\"\nexpand = \"v{i}\"\n"));
951        }
952        assert!(parse_config(&s).is_ok(), "must accept exactly 10,000 abbr rules");
953    }
954
955    #[test]
956    fn load_config_rejects_oversized_file() {
957        use std::io::Write;
958        let mut f = tempfile::NamedTempFile::new().unwrap();
959        f.write_all(&vec![b'x'; 11 * 1024 * 1024]).unwrap();
960        f.flush().unwrap();
961        assert!(load_config(f.path()).is_err(), "must reject files larger than 10 MB");
962    }
963
964    /// On Linux, a symlink to /dev/zero reports metadata().len() == 0, bypassing the
965    /// size guard. load_config must reject non-regular files.
966    #[test]
967    #[cfg(unix)]
968    fn load_config_rejects_symlink_to_dev_zero() {
969        let dir = tempfile::tempdir().unwrap();
970        let link = dir.path().join("fake_config.toml");
971        std::os::unix::fs::symlink("/dev/zero", &link).unwrap();
972        let err = load_config(&link);
973        assert!(err.is_err(), "load_config must reject a symlink to /dev/zero");
974    }
975
976    /// A symlink pointing to a regular TOML file must be followed.
977    /// This supports the common dotfiles pattern where ~/.config/runex/config.toml
978    /// is a symlink into a dotfiles repository.
979    #[test]
980    #[cfg(unix)]
981    fn load_config_follows_symlink_to_regular_file() {
982        let dir = tempfile::tempdir().unwrap();
983        let target = dir.path().join("target.toml");
984        std::fs::write(&target, b"version = 1\n").unwrap();
985        let link = dir.path().join("link_config.toml");
986        std::os::unix::fs::symlink(&target, &link).unwrap();
987        let result = load_config(&link);
988        assert!(result.is_ok(), "load_config must follow a symlink to a regular file: {result:?}");
989    }
990
991    /// A named pipe reports metadata().len() == 0 and read_to_string() blocks.
992    /// load_config must reject non-regular files before attempting to read.
993    #[test]
994    #[cfg(unix)]
995    fn load_config_rejects_named_pipe() {
996        use std::ffi::CString;
997        let dir = tempfile::tempdir().unwrap();
998        let pipe = dir.path().join("fake_config.toml");
999        let path_c = CString::new(pipe.to_str().unwrap()).unwrap();
1000        unsafe { libc::mkfifo(path_c.as_ptr(), 0o600) };
1001        let err = load_config(&pipe);
1002        assert!(err.is_err(), "load_config must reject a named pipe");
1003    }
1004
1005    } // mod parsing
1006
1007    mod field_validation {
1008        use super::*;
1009
1010    #[test]
1011    fn parse_config_rejects_oversized_key() {
1012        let huge_key = "k".repeat(1025);
1013        let toml = format!("version = 1\n[[abbr]]\nkey = \"{huge_key}\"\nexpand = \"v\"\n");
1014        assert!(parse_config(&toml).is_err(), "must reject key longer than 1024 bytes");
1015    }
1016
1017    #[test]
1018    fn parse_config_accepts_max_key_length() {
1019        let max_key = "k".repeat(1024);
1020        let toml = format!("version = 1\n[[abbr]]\nkey = \"{max_key}\"\nexpand = \"v\"\n");
1021        assert!(parse_config(&toml).is_ok(), "must accept key of exactly 1024 bytes");
1022    }
1023
1024    #[test]
1025    fn parse_config_rejects_oversized_expand() {
1026        let huge_expand = "x".repeat(4097);
1027        let toml = format!("version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"{huge_expand}\"\n");
1028        assert!(parse_config(&toml).is_err(), "must reject expand longer than 4096 bytes");
1029    }
1030
1031    #[test]
1032    fn parse_config_accepts_max_expand_length() {
1033        let max_expand = "x".repeat(4096);
1034        let toml = format!("version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"{max_expand}\"\n");
1035        assert!(parse_config(&toml).is_ok(), "must accept expand of exactly 4096 bytes");
1036    }
1037
1038    #[test]
1039    fn parse_config_rejects_oversized_when_command_exists_entry() {
1040        let huge_cmd = "c".repeat(256);
1041        let toml = format!(
1042            "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\"\nwhen_command_exists = [\"{huge_cmd}\"]\n"
1043        );
1044        assert!(parse_config(&toml).is_err(), "must reject when_command_exists entry longer than 255 bytes");
1045    }
1046
1047    #[test]
1048    fn parse_config_accepts_max_when_command_exists_entry() {
1049        let max_cmd = "c".repeat(255);
1050        let toml = format!(
1051            "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\"\nwhen_command_exists = [\"{max_cmd}\"]\n"
1052        );
1053        assert!(parse_config(&toml).is_ok(), "must accept when_command_exists entry of exactly 255 bytes");
1054    }
1055
1056    #[test]
1057    fn parse_config_rejects_nul_byte_in_when_command_exists() {
1058        let toml = "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\"\nwhen_command_exists = [\"cmd\\u0000evil\"]\n";
1059        assert!(parse_config(toml).is_err(), "must reject when_command_exists entry containing NUL byte");
1060    }
1061
1062    #[test]
1063    fn parse_config_rejects_nul_byte_in_key() {
1064        let toml = "version = 1\n[[abbr]]\nkey = \"k\\u0000evil\"\nexpand = \"v\"\n";
1065        assert!(parse_config(toml).is_err(), "must reject key containing NUL byte");
1066    }
1067
1068    #[test]
1069    fn parse_config_rejects_nul_byte_in_expand() {
1070        let toml = "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\\u0000evil\"\n";
1071        assert!(parse_config(toml).is_err(), "must reject expand containing NUL byte");
1072    }
1073
1074    } // mod field_validation
1075
1076    /// TOML allows `\uXXXX` escapes for any Unicode code point, including ASCII
1077    /// control characters (U+0001–U+001F, U+007F). These pass through `toml::from_str`
1078    /// but must be rejected by `parse_config` because:
1079    /// - key: quoting functions silently drop them, making the key unmatchable
1080    /// - expand: the expansion is silently mangled when printed
1081    /// - both: users get silent wrong behavior instead of a clear error
1082    mod control_char_rejection {
1083        use super::*;
1084
1085    #[test]
1086    fn parse_config_rejects_control_char_in_key() {
1087        let toml = "version = 1\n[[abbr]]\nkey = \"k\\u001Bevil\"\nexpand = \"v\"\n";
1088        assert!(
1089            parse_config(toml).is_err(),
1090            "must reject key containing ASCII control char (\\u001B)"
1091        );
1092    }
1093
1094    #[test]
1095    fn parse_config_rejects_control_char_in_expand() {
1096        let toml = "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\\u001Bevil\"\n";
1097        assert!(
1098            parse_config(toml).is_err(),
1099            "must reject expand containing ASCII control char (\\u001B)"
1100        );
1101    }
1102
1103    #[test]
1104    fn parse_config_rejects_del_in_key() {
1105        let toml = "version = 1\n[[abbr]]\nkey = \"k\\u007Fevil\"\nexpand = \"v\"\n";
1106        assert!(
1107            parse_config(toml).is_err(),
1108            "must reject key containing DEL (\\u007F)"
1109        );
1110    }
1111
1112    #[test]
1113    fn parse_config_rejects_del_in_expand() {
1114        let toml = "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\\u007Fevil\"\n";
1115        assert!(
1116            parse_config(toml).is_err(),
1117            "must reject expand containing DEL (\\u007F)"
1118        );
1119    }
1120
1121    #[test]
1122    fn parse_config_accepts_key_without_control_chars() {
1123        let toml = "version = 1\n[[abbr]]\nkey = \"gcm\"\nexpand = \"git commit -m\"\n";
1124        assert!(parse_config(toml).is_ok(), "must accept key without control chars");
1125    }
1126
1127    /// An empty key produces `''` in bash/zsh case statements, which matches
1128    /// the empty string — any empty-token expansion would silently fire.
1129    /// Reject early with a clear error rather than producing a broken script.
1130    #[test]
1131    fn parse_config_rejects_empty_key() {
1132        let toml = "version = 1\n[[abbr]]\nkey = \"\"\nexpand = \"git commit -m\"\n";
1133        assert!(
1134            parse_config(toml).is_err(),
1135            "must reject an abbr rule with an empty key"
1136        );
1137    }
1138
1139    /// A key consisting only of spaces would be silently dropped by quoting functions,
1140    /// making the rule unmatchable while appearing valid.
1141    #[test]
1142    fn parse_config_rejects_whitespace_only_key() {
1143        let toml = "version = 1\n[[abbr]]\nkey = \"   \"\nexpand = \"git commit -m\"\n";
1144        assert!(
1145            parse_config(toml).is_err(),
1146            "must reject an abbr rule with a whitespace-only key"
1147        );
1148    }
1149
1150    /// An empty string in `when_command_exists` is meaningless: `which::which("")` always
1151    /// fails, silently causing the rule to never expand.
1152    #[test]
1153    fn parse_config_rejects_empty_when_command_exists_entry() {
1154        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"lsd\"\nwhen_command_exists = [\"\"]\n";
1155        assert!(
1156            parse_config(toml).is_err(),
1157            "must reject when_command_exists entry that is an empty string"
1158        );
1159    }
1160
1161    /// A whitespace-only command name silently makes the rule permanently inactive.
1162    #[test]
1163    fn parse_config_rejects_whitespace_only_when_command_exists_entry() {
1164        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"lsd\"\nwhen_command_exists = [\"   \"]\n";
1165        assert!(
1166            parse_config(toml).is_err(),
1167            "must reject when_command_exists entry that is whitespace-only"
1168        );
1169    }
1170
1171    #[test]
1172    fn parse_config_rejects_control_char_in_when_command_exists() {
1173        let toml = "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\"\nwhen_command_exists = [\"cmd\\u001Bevil\"]\n";
1174        assert!(
1175            parse_config(toml).is_err(),
1176            "must reject when_command_exists entry containing ASCII control char (\\u001B)"
1177        );
1178    }
1179
1180    #[test]
1181    fn parse_config_rejects_del_in_when_command_exists() {
1182        let toml = "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\"\nwhen_command_exists = [\"cmd\\u007Fevil\"]\n";
1183        assert!(
1184            parse_config(toml).is_err(),
1185            "must reject when_command_exists entry containing DEL (\\u007F)"
1186        );
1187    }
1188
1189    } // mod control_char_rejection
1190
1191    /// Characters such as U+FEFF (BOM/zero-width no-break space), U+202E (Right-to-Left
1192    /// Override), and other Unicode formatting/invisible characters cannot be seen in most
1193    /// terminals and text editors. If embedded in `key`, `expand`, or `when_command_exists`,
1194    /// they cause:
1195    /// - `key`: rule appears valid but never matches (invisible difference from real command)
1196    /// - `expand`: expansion contains invisible/deceptive text printed to terminal
1197    /// - `when_command_exists`: command lookup silently fails forever
1198    /// - `list` output: shows a key that looks like "ls" but is really `"\u{FEFF}ls"`
1199    ///
1200    /// These must be rejected early with a clear error.
1201    mod deceptive_unicode {
1202        use super::*;
1203
1204    #[test]
1205    fn parse_config_rejects_bom_in_key() {
1206        let toml = "version = 1\n[[abbr]]\nkey = \"\\uFEFFls\"\nexpand = \"lsd\"\n";
1207        assert!(
1208            parse_config(toml).is_err(),
1209            "must reject key containing U+FEFF (BOM / zero-width no-break space)"
1210        );
1211    }
1212
1213    #[test]
1214    fn parse_config_rejects_rlo_in_key() {
1215        let toml = "version = 1\n[[abbr]]\nkey = \"ab\\u202Ecd\"\nexpand = \"v\"\n";
1216        assert!(
1217            parse_config(toml).is_err(),
1218            "must reject key containing U+202E (Right-to-Left Override)"
1219        );
1220    }
1221
1222    #[test]
1223    fn parse_config_rejects_rlo_in_expand() {
1224        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"rm -rf \\u202E/ echo safe\"\n";
1225        assert!(
1226            parse_config(toml).is_err(),
1227            "must reject expand containing U+202E (Right-to-Left Override)"
1228        );
1229    }
1230
1231    #[test]
1232    fn parse_config_rejects_bom_in_expand() {
1233        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"lsd\\uFEFF\"\n";
1234        assert!(
1235            parse_config(toml).is_err(),
1236            "must reject expand containing U+FEFF (BOM)"
1237        );
1238    }
1239
1240    #[test]
1241    fn parse_config_rejects_bom_in_when_command_exists() {
1242        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"lsd\"\nwhen_command_exists = [\"\\uFEFFlsd\"]\n";
1243        assert!(
1244            parse_config(toml).is_err(),
1245            "must reject when_command_exists entry containing U+FEFF (BOM)"
1246        );
1247    }
1248
1249    #[test]
1250    fn parse_config_rejects_zwsp_in_key() {
1251        let toml = "version = 1\n[[abbr]]\nkey = \"ls\\u200Bcd\"\nexpand = \"v\"\n";
1252        assert!(
1253            parse_config(toml).is_err(),
1254            "must reject key containing U+200B (Zero-Width Space)"
1255        );
1256    }
1257
1258    /// `when_command_exists` values must be bare command names, not filesystem paths.
1259    /// A value like `"/usr/bin/ls"` is a path traversal attempt: `dir.join("/usr/bin/ls")`
1260    /// on Unix resolves to an absolute path, bypassing the intended restriction to check
1261    /// only within `path_prepend`.
1262    #[test]
1263    fn parse_config_rejects_path_separator_in_when_command_exists() {
1264        for bad in ["/usr/bin/ls", "../../evil", "../bin/sh"] {
1265            let toml = format!(
1266                "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"lsd\"\nwhen_command_exists = [\"{bad}\"]\n"
1267            );
1268            assert!(
1269                parse_config(&toml).is_err(),
1270                "must reject when_command_exists entry containing '/': {bad:?}"
1271            );
1272        }
1273    }
1274
1275    /// On Windows, backslash is a path separator. Paths like `C:\bin\ls` must be
1276    /// caught at parse time before they reach `make_command_exists`.
1277    #[test]
1278    fn parse_config_rejects_backslash_in_when_command_exists() {
1279        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"lsd\"\nwhen_command_exists = [\"bin\\\\ls\"]\n";
1280        assert!(
1281            parse_config(toml).is_err(),
1282            "must reject when_command_exists entry containing backslash"
1283        );
1284    }
1285
1286    /// A colon introduces a Windows drive letter (e.g. `C:ls`) or acts as a
1287    /// PATH-like separator in some contexts.
1288    #[test]
1289    fn parse_config_rejects_colon_in_when_command_exists() {
1290        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"lsd\"\nwhen_command_exists = [\"C:ls\"]\n";
1291        assert!(
1292            parse_config(toml).is_err(),
1293            "must reject when_command_exists entry containing colon"
1294        );
1295    }
1296
1297    /// Shell/cmd metacharacters could be injected into shell-side precache
1298    /// detection loops (e.g. clink's `io.popen("where " .. cmd)`) and allow
1299    /// arbitrary command execution at shell startup.
1300    #[test]
1301    fn parse_config_rejects_shell_metacharacters_in_when_command_exists() {
1302        let bad_entries = [
1303            "a&b", "a|b", "a;b", "a<b", "a>b", "a`b", "a$b",
1304            "a(b", "a)b", "a{b", "a}b", "a\"b", "a'b",
1305            "a%b", "a^b",  // cmd.exe
1306            "a b", "a\tb",  // whitespace breaks tokenization
1307            "a*b", "a?b", "a[b", "a]b",  // glob
1308            "a,b", "a=b",  // precache --resolved delimiters
1309            "a!b", "a#b", "a~b",  // other risky punctuation
1310        ];
1311        for bad in &bad_entries {
1312            let toml = format!(
1313                "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"lsd\"\nwhen_command_exists = [\"{bad}\"]\n"
1314            );
1315            assert!(
1316                parse_config(&toml).is_err(),
1317                "must reject when_command_exists entry containing metachar: {bad:?}"
1318            );
1319        }
1320    }
1321
1322    #[test]
1323    fn parse_config_accepts_bare_command_name_in_when_command_exists() {
1324        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"lsd\"\nwhen_command_exists = [\"lsd\"]\n";
1325        assert!(
1326            parse_config(toml).is_ok(),
1327            "must accept bare command name in when_command_exists"
1328        );
1329    }
1330
1331    } // mod deceptive_unicode
1332
1333    /// Each abbr rule's `when_command_exists` list is iterated on every expand call.
1334    /// Without a cap, a config with 100,000 entries would cause:
1335    /// - ~25 MB memory per rule (100,000 × 255 bytes)
1336    /// - 100,000 `which::which()` calls per keystroke — CPU/I/O DoS
1337    ///
1338    /// Capped at `MAX_CMD_LIST_LEN` entries per rule.
1339    mod when_command_exists_limit {
1340        use super::*;
1341
1342    #[test]
1343    fn parse_config_rejects_too_many_when_command_exists_entries() {
1344        let cmds: Vec<String> = (0..=64).map(|i| format!("\"cmd{i}\"")).collect();
1345        let toml = format!(
1346            "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\"\nwhen_command_exists = [{}]\n",
1347            cmds.join(", ")
1348        );
1349        assert!(
1350            parse_config(&toml).is_err(),
1351            "must reject when_command_exists with more than 64 entries"
1352        );
1353    }
1354
1355    #[test]
1356    fn parse_config_accepts_max_when_command_exists_entries() {
1357        let cmds: Vec<String> = (0..64).map(|i| format!("\"cmd{i}\"")).collect();
1358        let toml = format!(
1359            "version = 1\n[[abbr]]\nkey = \"k\"\nexpand = \"v\"\nwhen_command_exists = [{}]\n",
1360            cmds.join(", ")
1361        );
1362        assert!(
1363            parse_config(&toml).is_ok(),
1364            "must accept when_command_exists with exactly 64 entries"
1365        );
1366    }
1367
1368    } // mod when_command_exists_limit
1369
1370    /// The only supported config schema version is 1. A config file with version=2
1371    /// (or any other value) was written for a different schema and must be rejected
1372    /// rather than silently processed as version=1. Accepting unknown versions risks
1373    /// missing new validation rules introduced in a later schema.
1374    mod version_validation {
1375        use super::*;
1376
1377    #[test]
1378    fn parse_config_rejects_version_0() {
1379        let toml = "version = 0\n";
1380        assert!(
1381            parse_config(toml).is_err(),
1382            "must reject version=0 (unsupported schema version)"
1383        );
1384    }
1385
1386    #[test]
1387    fn parse_config_rejects_version_2() {
1388        let toml = "version = 2\n";
1389        assert!(
1390            parse_config(toml).is_err(),
1391            "must reject version=2 (unsupported schema version)"
1392        );
1393    }
1394
1395    #[test]
1396    fn parse_config_rejects_version_99() {
1397        let toml = "version = 99\n";
1398        assert!(
1399            parse_config(toml).is_err(),
1400            "must reject version=99 (unsupported schema version)"
1401        );
1402    }
1403
1404    #[test]
1405    fn parse_config_accepts_version_1() {
1406        let toml = "version = 1\n";
1407        assert!(
1408            parse_config(toml).is_ok(),
1409            "must accept version=1 (the current supported schema)"
1410        );
1411    }
1412
1413    } // mod version_validation
1414
1415    /// An expand value that is empty or whitespace-only is functionally broken:
1416    /// - Empty: pressing the trigger key replaces the token with nothing — almost certainly a mistake.
1417    /// - Whitespace-only: replaces the token with invisible characters — confusing and unintended.
1418    ///
1419    /// Both are rejected early so users get a clear error rather than silent breakage.
1420    mod expand_validation {
1421        use super::*;
1422
1423    #[test]
1424    fn parse_config_rejects_empty_expand() {
1425        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"\"\n";
1426        assert!(
1427            parse_config(toml).is_err(),
1428            "must reject an abbr rule with an empty expand"
1429        );
1430    }
1431
1432    #[test]
1433    fn parse_config_rejects_whitespace_only_expand() {
1434        let toml = "version = 1\n[[abbr]]\nkey = \"ls\"\nexpand = \"   \"\n";
1435        assert!(
1436            parse_config(toml).is_err(),
1437            "must reject an abbr rule with a whitespace-only expand"
1438        );
1439    }
1440
1441    #[test]
1442    fn parse_config_accepts_normal_expand() {
1443        let toml = "version = 1\n[[abbr]]\nkey = \"gcm\"\nexpand = \"git commit -m\"\n";
1444        assert!(
1445            parse_config(toml).is_ok(),
1446            "must accept a normal non-empty expand value"
1447        );
1448    }
1449
1450    } // mod expand_validation
1451
1452    mod add_remove {
1453        use super::*;
1454
1455    #[test]
1456    fn append_abbr_creates_valid_config() {
1457        let dir = tempfile::tempdir().unwrap();
1458        let path = dir.path().join("config.toml");
1459        std::fs::write(&path, "version = 1\n").unwrap();
1460
1461        append_abbr_to_file(&path, "gcm", "git commit -m", None).unwrap();
1462
1463        let config = load_config(&path).unwrap();
1464        assert_eq!(config.abbr.len(), 1);
1465        assert_eq!(config.abbr[0].key, "gcm");
1466    }
1467
1468    #[test]
1469    fn append_abbr_with_when_command_exists() {
1470        let dir = tempfile::tempdir().unwrap();
1471        let path = dir.path().join("config.toml");
1472        std::fs::write(&path, "version = 1\n").unwrap();
1473
1474        let cmds = vec!["lsd".to_string()];
1475        append_abbr_to_file(&path, "ls", "lsd", Some(&cmds)).unwrap();
1476
1477        let config = load_config(&path).unwrap();
1478        assert_eq!(config.abbr[0].key, "ls");
1479        assert!(config.abbr[0].when_command_exists.is_some());
1480    }
1481
1482    #[test]
1483    fn append_abbr_preserves_existing() {
1484        let dir = tempfile::tempdir().unwrap();
1485        let path = dir.path().join("config.toml");
1486        std::fs::write(&path, r#"version = 1
1487
1488[[abbr]]
1489key = "gp"
1490expand = "git push"
1491"#).unwrap();
1492
1493        append_abbr_to_file(&path, "gcm", "git commit -m", None).unwrap();
1494
1495        let config = load_config(&path).unwrap();
1496        assert_eq!(config.abbr.len(), 2);
1497        assert_eq!(config.abbr[0].key, "gp");
1498        assert_eq!(config.abbr[1].key, "gcm");
1499    }
1500
1501    #[test]
1502    fn append_abbr_rejects_invalid_key() {
1503        let dir = tempfile::tempdir().unwrap();
1504        let path = dir.path().join("config.toml");
1505        std::fs::write(&path, "version = 1\n").unwrap();
1506
1507        assert!(append_abbr_to_file(&path, "", "git commit", None).is_err());
1508    }
1509
1510    #[test]
1511    fn remove_abbr_deletes_matching_key() {
1512        let dir = tempfile::tempdir().unwrap();
1513        let path = dir.path().join("config.toml");
1514        std::fs::write(&path, r#"version = 1
1515
1516[[abbr]]
1517key = "gcm"
1518expand = "git commit -m"
1519
1520[[abbr]]
1521key = "gp"
1522expand = "git push"
1523"#).unwrap();
1524
1525        let removed = remove_abbr_from_file(&path, "gcm").unwrap();
1526        assert_eq!(removed, 1);
1527
1528        let config = load_config(&path).unwrap();
1529        assert_eq!(config.abbr.len(), 1);
1530        assert_eq!(config.abbr[0].key, "gp");
1531    }
1532
1533    #[test]
1534    fn remove_abbr_returns_zero_for_missing_key() {
1535        let dir = tempfile::tempdir().unwrap();
1536        let path = dir.path().join("config.toml");
1537        std::fs::write(&path, r#"version = 1
1538
1539[[abbr]]
1540key = "gcm"
1541expand = "git commit -m"
1542"#).unwrap();
1543
1544        let removed = remove_abbr_from_file(&path, "xyz").unwrap();
1545        assert_eq!(removed, 0);
1546    }
1547
1548    } // mod add_remove
1549
1550    mod validation_walker {
1551        use super::*;
1552        use crate::model::{Abbr, PerShellCmds, PerShellString};
1553
1554        fn make_config(abbrs: Vec<Abbr>) -> Config {
1555            Config {
1556                version: 1,
1557                keybind: crate::model::KeybindConfig::default(),
1558                precache: crate::model::PrecacheConfig::default(),
1559                abbr: abbrs,
1560            }
1561        }
1562
1563        fn abbr(key: &str, expand: &str) -> Abbr {
1564            Abbr {
1565                key: key.into(),
1566                expand: PerShellString::All(expand.into()),
1567                when_command_exists: None,
1568            }
1569        }
1570
1571        #[test]
1572        fn collect_issues_empty_for_valid_config() {
1573            let cfg = make_config(vec![abbr("gcm", "git commit -m")]);
1574            assert!(collect_validation_issues(&cfg).is_empty());
1575        }
1576
1577        #[test]
1578        fn collect_issues_finds_multiple_rejected_rules() {
1579            let cfg = make_config(vec![
1580                abbr("", "not empty"),                // empty key
1581                abbr("lsa", ""),                      // empty expand
1582                abbr("valid", "echo ok"),
1583            ]);
1584            let issues = collect_validation_issues(&cfg);
1585            assert_eq!(issues.len(), 2);
1586            // ordering: rule[0].key then rule[1].expand
1587            match &issues[0] {
1588                ValidationIssue::Rule { rule_index, field_path, reason } => {
1589                    assert_eq!(*rule_index, 1);
1590                    assert_eq!(field_path, "key");
1591                    assert_eq!(*reason, ValidationReason::KeyEmpty);
1592                }
1593                other => panic!("expected Rule, got {other:?}"),
1594            }
1595            match &issues[1] {
1596                ValidationIssue::Rule { rule_index, field_path, reason } => {
1597                    assert_eq!(*rule_index, 2);
1598                    assert_eq!(field_path, "expand");
1599                    assert_eq!(*reason, ValidationReason::ExpandEmpty);
1600                }
1601                other => panic!("expected Rule, got {other:?}"),
1602            }
1603        }
1604
1605        #[test]
1606        fn collect_issues_reports_per_shell_expand_path() {
1607            // `default` is valid, `pwsh` is empty.
1608            let cfg = make_config(vec![Abbr {
1609                key: "gcm".into(),
1610                expand: PerShellString::ByShell {
1611                    default: Some("git commit -m".into()),
1612                    bash: None,
1613                    zsh: None,
1614                    pwsh: Some("".into()),
1615                    nu: None,
1616                },
1617                when_command_exists: None,
1618            }]);
1619            let issues = collect_validation_issues(&cfg);
1620            assert_eq!(issues.len(), 1);
1621            match &issues[0] {
1622                ValidationIssue::Rule { rule_index, field_path, reason } => {
1623                    assert_eq!(*rule_index, 1);
1624                    assert_eq!(field_path, "expand.pwsh");
1625                    assert_eq!(*reason, ValidationReason::ExpandEmpty);
1626                }
1627                other => panic!("expected Rule, got {other:?}"),
1628            }
1629        }
1630
1631        #[test]
1632        fn collect_issues_reports_when_command_exists_index_1_based() {
1633            let cfg = make_config(vec![Abbr {
1634                key: "ls".into(),
1635                expand: PerShellString::All("lsd".into()),
1636                when_command_exists: Some(PerShellCmds::All(vec![
1637                    "good".into(),
1638                    "bad&inject".into(),  // metachar at list position 2 (1-based)
1639                ])),
1640            }]);
1641            let issues = collect_validation_issues(&cfg);
1642            assert_eq!(issues.len(), 1);
1643            match &issues[0] {
1644                ValidationIssue::Rule { rule_index, field_path, reason } => {
1645                    assert_eq!(*rule_index, 1);
1646                    assert_eq!(field_path, "when_command_exists[2]");
1647                    assert_eq!(*reason, ValidationReason::CmdContainsMetacharacter);
1648                }
1649                other => panic!("expected Rule, got {other:?}"),
1650            }
1651        }
1652
1653        #[test]
1654        fn collect_issues_reports_per_shell_cmds_path() {
1655            let cfg = make_config(vec![Abbr {
1656                key: "ls".into(),
1657                expand: PerShellString::All("lsd".into()),
1658                when_command_exists: Some(PerShellCmds::ByShell {
1659                    default: Some(vec!["ok".into()]),
1660                    bash: None,
1661                    zsh: None,
1662                    pwsh: Some(vec!["Get-Item".into(), "bad|cmd".into()]),
1663                    nu: None,
1664                }),
1665            }]);
1666            let issues = collect_validation_issues(&cfg);
1667            assert_eq!(issues.len(), 1);
1668            match &issues[0] {
1669                ValidationIssue::Rule { field_path, .. } => {
1670                    assert_eq!(field_path, "when_command_exists.pwsh[2]");
1671                }
1672                other => panic!("expected Rule, got {other:?}"),
1673            }
1674        }
1675
1676        #[test]
1677        fn first_validation_error_preserves_rule_order() {
1678            let cfg = make_config(vec![
1679                abbr("", "x"),       // empty key (rule #1)
1680                abbr("gcm", ""),     // empty expand (rule #2) — earlier rule wins
1681            ]);
1682            let err = first_validation_error(&cfg).expect("must fail");
1683            assert!(matches!(err, ConfigError::KeyEmpty(1)), "got {err:?}");
1684        }
1685
1686        #[test]
1687        fn first_validation_error_preserves_key_before_expand() {
1688            let cfg = make_config(vec![abbr("", "")]);
1689            let err = first_validation_error(&cfg).expect("must fail");
1690            assert!(matches!(err, ConfigError::KeyEmpty(1)), "got {err:?}");
1691        }
1692
1693        #[test]
1694        fn first_validation_error_preserves_too_many_rules_before_rule_validation() {
1695            let mut abbrs = Vec::new();
1696            for _ in 0..=MAX_ABBR_RULES {
1697                abbrs.push(abbr("", "x")); // every one is invalid
1698            }
1699            let cfg = make_config(abbrs);
1700            let err = first_validation_error(&cfg).expect("must fail");
1701            assert!(matches!(err, ConfigError::TooManyRules), "got {err:?}");
1702        }
1703
1704        #[test]
1705        fn first_validation_error_preserves_too_many_cmds_before_bad_cmd_entry() {
1706            let mut cmds = Vec::new();
1707            for _ in 0..=MAX_CMD_LIST_LEN {
1708                cmds.push("bad&entry".into()); // every one is invalid
1709            }
1710            let cfg = make_config(vec![Abbr {
1711                key: "ls".into(),
1712                expand: PerShellString::All("lsd".into()),
1713                when_command_exists: Some(PerShellCmds::All(cmds)),
1714            }]);
1715            let err = first_validation_error(&cfg).expect("must fail");
1716            assert!(matches!(err, ConfigError::TooManyCmds(1)), "got {err:?}");
1717        }
1718
1719        #[test]
1720        fn first_validation_error_preserves_per_shell_expand_order() {
1721            // both `default` and `pwsh` are empty → default (index 0) should win.
1722            let cfg = make_config(vec![Abbr {
1723                key: "gcm".into(),
1724                expand: PerShellString::ByShell {
1725                    default: Some("".into()),
1726                    bash: None,
1727                    zsh: None,
1728                    pwsh: Some("".into()),
1729                    nu: None,
1730                },
1731                when_command_exists: None,
1732            }]);
1733            let err = first_validation_error(&cfg).expect("must fail");
1734            assert!(matches!(err, ConfigError::ExpandEmpty(1)), "got {err:?}");
1735        }
1736
1737        #[test]
1738        fn collect_issues_reports_multiple_issues_in_one_rule_in_order() {
1739            // Bad key + bad expand.pwsh + bad cmd entry — all three should be reported
1740            // in key → expand.pwsh → when_command_exists[1] order.
1741            let cfg = make_config(vec![Abbr {
1742                key: "".into(),
1743                expand: PerShellString::ByShell {
1744                    default: Some("git commit -m".into()),
1745                    bash: None,
1746                    zsh: None,
1747                    pwsh: Some("".into()),
1748                    nu: None,
1749                },
1750                when_command_exists: Some(PerShellCmds::All(vec!["bad&entry".into()])),
1751            }]);
1752            let issues = collect_validation_issues(&cfg);
1753            assert_eq!(issues.len(), 3);
1754            match &issues[0] {
1755                ValidationIssue::Rule { field_path, reason, .. } => {
1756                    assert_eq!(field_path, "key");
1757                    assert_eq!(*reason, ValidationReason::KeyEmpty);
1758                }
1759                other => panic!("expected Rule, got {other:?}"),
1760            }
1761            match &issues[1] {
1762                ValidationIssue::Rule { field_path, reason, .. } => {
1763                    assert_eq!(field_path, "expand.pwsh");
1764                    assert_eq!(*reason, ValidationReason::ExpandEmpty);
1765                }
1766                other => panic!("expected Rule, got {other:?}"),
1767            }
1768            match &issues[2] {
1769                ValidationIssue::Rule { field_path, reason, .. } => {
1770                    assert_eq!(field_path, "when_command_exists[1]");
1771                    assert_eq!(*reason, ValidationReason::CmdContainsMetacharacter);
1772                }
1773                other => panic!("expected Rule, got {other:?}"),
1774            }
1775        }
1776    } // mod validation_walker
1777}