1use std::collections::HashSet;
2
3fn 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
12pub fn scope_command_to_changeset(command: &str, changed_files: &[String]) -> Option<String> {
16 let trimmed = command.trim();
17
18 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 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 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}