use std::path::Path;
use crate::config::ResolvedConfig;
pub fn strip_verbatim(s: &str) -> String {
if let Some(rest) = s.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{rest}");
}
if let Some(rest) = s.strip_prefix(r"\\?\") {
return rest.to_string();
}
s.to_string()
}
pub fn normalize_path(p: &Path) -> String {
strip_verbatim(&p.to_string_lossy()).replace('\\', "/")
}
pub fn vim_path(p: &Path) -> String {
let stripped = strip_verbatim(&p.to_string_lossy());
if stripped.starts_with(r"\\") {
stripped
} else {
stripped.replace('\\', "/")
}
}
pub fn first_match(cfg: &ResolvedConfig, subject: &str) -> Option<usize> {
for (i, regexes) in cfg.rule_regexes.iter().enumerate() {
let matched = regexes.iter().any(|re| re.is_match(subject));
if !matched {
continue;
}
let excluded = cfg.rule_excludes[i].iter().any(|re| re.is_match(subject));
if excluded {
continue;
}
return Some(i);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::load_from_str;
use std::path::PathBuf;
#[test]
fn normalize_strips_verbatim_prefix() {
let p = PathBuf::from(r"\\?\C:\Users\x\file.txt");
assert_eq!(normalize_path(&p), "C:/Users/x/file.txt");
}
#[test]
fn normalize_converts_backslashes() {
let p = PathBuf::from(r"C:\Users\x\file.txt");
assert_eq!(normalize_path(&p), "C:/Users/x/file.txt");
}
#[test]
fn normalize_unix_passthrough() {
let p = PathBuf::from("/home/x/file.txt");
assert_eq!(normalize_path(&p), "/home/x/file.txt");
}
#[test]
fn normalize_unc_verbatim() {
let p = PathBuf::from(r"\\?\UNC\server\share\file.txt");
assert_eq!(normalize_path(&p), "//server/share/file.txt");
}
#[test]
fn normalize_unc_plain() {
let p = PathBuf::from(r"\\server\share\file.txt");
assert_eq!(normalize_path(&p), "//server/share/file.txt");
}
#[test]
fn vim_path_preserves_unc_backslashes() {
let p = PathBuf::from(r"\\server\share\file.txt");
assert_eq!(vim_path(&p), r"\\server\share\file.txt");
}
#[test]
fn vim_path_converts_verbatim_unc() {
let p = PathBuf::from(r"\\?\UNC\server\share\file.txt");
assert_eq!(vim_path(&p), r"\\server\share\file.txt");
}
#[test]
fn vim_path_local_uses_forward_slashes() {
let p = PathBuf::from(r"C:\Users\x\file.txt");
assert_eq!(vim_path(&p), "C:/Users/x/file.txt");
}
#[test]
fn vim_path_strips_verbatim_local() {
let p = PathBuf::from(r"\\?\C:\Users\x\file.txt");
assert_eq!(vim_path(&p), "C:/Users/x/file.txt");
}
#[test]
fn first_match_returns_first_hit() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
name = "rs"
match = '\.rs$'
to = "a"
[[rules]]
name = "any"
match = '.*'
to = "a"
"#;
let cfg = load_from_str(text).unwrap();
assert_eq!(first_match(&cfg, "/tmp/foo.rs"), Some(0));
assert_eq!(first_match(&cfg, "/tmp/README"), Some(1));
}
#[test]
fn first_match_none_when_no_rule_fits() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = '\.rs$'
to = "a"
"#;
let cfg = load_from_str(text).unwrap();
assert_eq!(first_match(&cfg, "/tmp/README.md"), None);
}
#[test]
fn array_match_is_or() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = ['\.rs$', '\.toml$']
to = "a"
"#;
let cfg = load_from_str(text).unwrap();
assert_eq!(first_match(&cfg, "/tmp/x.rs"), Some(0));
assert_eq!(first_match(&cfg, "/tmp/x.toml"), Some(0));
assert_eq!(first_match(&cfg, "/tmp/x.md"), None);
}
#[test]
fn exclude_single_pattern_skips_rule() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
name = "code"
match = '.*'
exclude = '\.md$'
to = "a"
[[rules]]
name = "fallback"
match = '.*'
to = "a"
"#;
let cfg = load_from_str(text).unwrap();
assert_eq!(first_match(&cfg, "/x/foo.rs"), Some(0));
assert_eq!(first_match(&cfg, "/x/foo.md"), Some(1));
}
#[test]
fn exclude_array_is_or() {
let text = r#"
[todoke.a]
command = "echo"
[[rules]]
match = '.*'
exclude = ['\.md$', '/tmp/']
to = "a"
"#;
let cfg = load_from_str(text).unwrap();
assert_eq!(first_match(&cfg, "/x/foo.rs"), Some(0));
assert_eq!(first_match(&cfg, "/x/foo.md"), None);
assert_eq!(first_match(&cfg, "/tmp/foo.rs"), None);
}
#[test]
fn url_subjects_match_as_is() {
let text = r#"
[todoke.firefox]
command = "firefox"
[[rules]]
match = '^https?://github\.com/'
to = "firefox"
"#;
let cfg = load_from_str(text).unwrap();
assert_eq!(first_match(&cfg, "https://github.com/owner/repo"), Some(0));
assert_eq!(first_match(&cfg, "https://gitlab.com/owner/repo"), None);
}
#[test]
fn default_config_matches_commit_editmsg() {
let cfg = load_from_str(crate::config::DEFAULT_CONFIG_TOML).unwrap();
let idx = first_match(&cfg, "/home/x/repo/.git/COMMIT_EDITMSG");
assert_eq!(idx, Some(0), "expected editor-callback rule to match");
assert_eq!(
cfg.rule(idx.unwrap()).name.as_deref(),
Some("editor-callback")
);
let idx = first_match(&cfg, "/home/x/notes/idea.md");
assert_eq!(idx, Some(1));
assert_eq!(cfg.rule(idx.unwrap()).name.as_deref(), Some("default"));
}
}