Skip to main content

dk_runner/
changeset.rs

1use std::collections::HashSet;
2
3/// Validate that a name is safe to use in a shell command argument.
4/// Only alphanumeric characters, hyphens, underscores, and dots are allowed.
5/// Must not start with a hyphen (to prevent flag injection).
6fn is_safe_name(name: &str) -> bool {
7    !name.is_empty()
8        && !name.starts_with('-')
9        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
10}
11
12/// Given a list of changed file paths and a base command, rewrite the command
13/// to scope it to only the affected packages/crates.
14/// Returns `None` if no scoping is possible (run the full command).
15pub fn scope_command_to_changeset(command: &str, changed_files: &[String]) -> Option<String> {
16    let trimmed = command.trim();
17
18    // Rust: "cargo test" → "cargo test -p crate1 -p crate2"
19    if trimmed.starts_with("cargo test")
20        || trimmed.starts_with("cargo check")
21        || trimmed.starts_with("cargo clippy")
22    {
23        let crates = extract_rust_crates(changed_files);
24        if crates.is_empty() {
25            return None;
26        }
27        let base = trimmed
28            .split_whitespace()
29            .take(2)
30            .collect::<Vec<_>>()
31            .join(" ");
32        let rest: Vec<&str> = trimmed.split_whitespace().skip(2).collect();
33        let pkg_flags: Vec<String> = crates.iter().map(|c| format!("-p {}", c)).collect();
34        let mut parts = vec![base];
35        parts.extend(pkg_flags.iter().map(|s| s.as_str().to_string()));
36        parts.extend(rest.iter().map(|s| s.to_string()));
37        return Some(parts.join(" "));
38    }
39
40    // TypeScript: "bun test" → "bun test dir1 dir2"
41    if trimmed.starts_with("bun test") {
42        let dirs = extract_ts_dirs(changed_files);
43        if dirs.is_empty() {
44            return None;
45        }
46        let mut parts = vec!["bun test".to_string()];
47        parts.extend(dirs);
48        return Some(parts.join(" "));
49    }
50
51    // Python: "pytest" → "pytest pkg1 pkg2"
52    if trimmed.starts_with("pytest") || trimmed.starts_with("python -m pytest") {
53        let pkgs = extract_python_packages(changed_files);
54        if pkgs.is_empty() {
55            return None;
56        }
57        let base = if trimmed.starts_with("python") {
58            "python -m pytest"
59        } else {
60            "pytest"
61        };
62        let mut parts = vec![base.to_string()];
63        parts.extend(pkgs);
64        return Some(parts.join(" "));
65    }
66
67    None
68}
69
70fn extract_rust_crates(files: &[String]) -> Vec<String> {
71    let mut crates: HashSet<String> = HashSet::new();
72    for f in files {
73        let parts: Vec<&str> = f.split('/').collect();
74        if parts.len() >= 2 && parts[0] == "crates" && is_safe_name(parts[1]) {
75            crates.insert(parts[1].to_string());
76        }
77    }
78    let mut sorted: Vec<String> = crates.into_iter().collect();
79    sorted.sort();
80    sorted
81}
82
83fn extract_ts_dirs(files: &[String]) -> Vec<String> {
84    let mut dirs: HashSet<String> = HashSet::new();
85    for f in files {
86        let parts: Vec<&str> = f.split('/').collect();
87        if parts.len() >= 2 && parts[0] == "src" && is_safe_name(parts[1]) {
88            dirs.insert(format!("src/{}", parts[1]));
89        }
90    }
91    let mut sorted: Vec<String> = dirs.into_iter().collect();
92    sorted.sort();
93    sorted
94}
95
96fn extract_python_packages(files: &[String]) -> Vec<String> {
97    let mut pkgs: HashSet<String> = HashSet::new();
98    for f in files {
99        let parts: Vec<&str> = f.split('/').collect();
100        if !parts.is_empty() && parts[0] != "tests" && is_safe_name(parts[0]) {
101            pkgs.insert(parts[0].to_string());
102        }
103    }
104    let mut sorted: Vec<String> = pkgs.into_iter().collect();
105    sorted.sort();
106    sorted
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn test_scope_cargo_test() {
115        let files = vec![
116            "crates/dk-engine/src/repo.rs".into(),
117            "crates/dk-core/src/lib.rs".into(),
118        ];
119        let scoped = scope_command_to_changeset("cargo test", &files).unwrap();
120        assert!(scoped.contains("-p dk-core"));
121        assert!(scoped.contains("-p dk-engine"));
122    }
123
124    #[test]
125    fn test_scope_cargo_test_with_flags() {
126        let files = vec!["crates/dk-cli/src/main.rs".into()];
127        let scoped = scope_command_to_changeset("cargo test --release", &files).unwrap();
128        assert!(scoped.contains("-p dk-cli"));
129        assert!(scoped.contains("--release"));
130    }
131
132    #[test]
133    fn test_scope_bun_test() {
134        let files = vec![
135            "src/components/Header.tsx".into(),
136            "src/pages/Home.tsx".into(),
137        ];
138        let scoped = scope_command_to_changeset("bun test", &files).unwrap();
139        assert!(scoped.contains("src/components"));
140        assert!(scoped.contains("src/pages"));
141    }
142
143    #[test]
144    fn test_scope_pytest() {
145        let files = vec!["mypackage/module.py".into()];
146        let scoped = scope_command_to_changeset("pytest", &files).unwrap();
147        assert!(scoped.contains("mypackage"));
148    }
149
150    #[test]
151    fn test_no_crates_returns_none() {
152        let files = vec!["README.md".into()];
153        assert!(scope_command_to_changeset("cargo test", &files).is_none());
154    }
155
156    #[test]
157    fn test_unknown_command_returns_none() {
158        let files = vec!["crates/dk-core/src/lib.rs".into()];
159        assert!(scope_command_to_changeset("make test", &files).is_none());
160    }
161
162    #[test]
163    fn test_malicious_path_rejected() {
164        let files = vec!["crates/$(evil)/src/lib.rs".into()];
165        assert!(scope_command_to_changeset("cargo test", &files).is_none());
166    }
167
168    #[test]
169    fn test_flag_injection_rejected() {
170        let files = vec!["--collect-only/foo.py".into()];
171        assert!(scope_command_to_changeset("pytest", &files).is_none());
172    }
173}