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; const 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#[derive(Debug, Clone, PartialEq, Eq)]
74pub(crate) enum ValidationReason {
75 TooManyRules,
77
78 KeyEmpty,
80 KeyWhitespaceOnly,
81 KeyTooLong,
82 KeyContainsNul,
83 KeyContainsControlChar,
84 KeyContainsDeceptiveUnicode,
85
86 ExpandEmpty,
88 ExpandWhitespaceOnly,
89 ExpandTooLong,
90 ExpandContainsNul,
91 ExpandContainsControlChar,
92 ExpandContainsDeceptiveUnicode,
93
94 TooManyCmds,
96
97 CmdEmpty,
99 CmdWhitespaceOnly,
100 CmdTooLong,
101 CmdContainsNul,
102 CmdContainsControlChar,
103 CmdContainsDeceptiveUnicode,
104 CmdContainsPathSeparator,
105 CmdContainsMetacharacter,
106}
107
108#[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 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 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
197fn 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
225fn 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
248fn 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 const METACHARS: &[char] = &[
281 '&', '|', ';', '<', '>', '`', '$', '(', ')', '{', '}', '\'', '"',
283 '%', '^',
285 ' ', '\t',
287 '*', '?', '[', ']',
289 ',', '=',
291 '!', '#', '~',
293 ];
294 if cmd.chars().any(|c| METACHARS.contains(&c)) {
295 return Some(ValidationReason::CmdContainsMetacharacter);
296 }
297 None
298}
299
300const PER_SHELL_LABELS: &[&str] = &["default", "bash", "zsh", "pwsh", "nu"];
303
304fn 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
346fn 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 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 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; }
389 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
407fn 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 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 if walk_expand_issues(&abbr.expand, rule_index, &mut f).is_break() {
434 return;
435 }
436 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
445pub(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
455fn 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
465pub(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
476pub 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
490pub 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
504pub(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
514pub fn load_config(path: &std::path::Path) -> Result<Config, ConfigError> {
525 let content = read_config_source(path)?;
526 parse_config(&content)
527}
528
529pub 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#[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 std::fs::OpenOptions::new().create(true).append(true).open(path)
581}
582
583fn 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 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
635pub 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; 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
675pub 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
712fn toml_quote(s: &str) -> String {
714 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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", "a b", "a\tb", "a*b", "a?b", "a[b", "a]b", "a,b", "a=b", "a!b", "a#b", "a~b", ];
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 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 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 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 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 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"), abbr("lsa", ""), abbr("valid", "echo ok"),
1583 ]);
1584 let issues = collect_validation_issues(&cfg);
1585 assert_eq!(issues.len(), 2);
1586 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 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(), ])),
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"), abbr("gcm", ""), ]);
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")); }
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()); }
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 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 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 } }