1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use crate::error::{Result, TestxError};
6
7#[derive(Debug, Clone)]
15pub enum DiffMode {
16 Head,
18 Staged,
20 Branch(String),
22 Commit(String),
24}
25
26impl DiffMode {
27 pub fn parse(s: &str) -> Result<Self> {
28 match s {
29 "head" | "HEAD" => Ok(DiffMode::Head),
30 "staged" | "STAGED" => Ok(DiffMode::Staged),
31 s if s.starts_with("branch:") => {
32 let branch = &s[7..];
33 if branch.is_empty() {
34 return Err(TestxError::ConfigError {
35 message: "Branch name cannot be empty in 'branch:<name>'".into(),
36 });
37 }
38 Ok(DiffMode::Branch(branch.to_string()))
39 }
40 s if s.starts_with("commit:") => {
41 let sha = &s[7..];
42 if sha.is_empty() {
43 return Err(TestxError::ConfigError {
44 message: "Commit SHA cannot be empty in 'commit:<sha>'".into(),
45 });
46 }
47 Ok(DiffMode::Commit(sha.to_string()))
48 }
49 other => Err(TestxError::ConfigError {
50 message: format!(
51 "Unknown diff mode '{}'. Use: head, staged, branch:<name>, commit:<sha>",
52 other
53 ),
54 }),
55 }
56 }
57
58 pub fn description(&self) -> String {
59 match self {
60 DiffMode::Head => "uncommitted changes vs HEAD".to_string(),
61 DiffMode::Staged => "staged changes".to_string(),
62 DiffMode::Branch(b) => format!("changes vs branch '{}'", b),
63 DiffMode::Commit(c) => format!("changes since commit '{}'", c),
64 }
65 }
66}
67
68pub fn get_changed_files(project_dir: &Path, mode: &DiffMode) -> Result<Vec<PathBuf>> {
70 let mut cmd = Command::new("git");
71 cmd.current_dir(project_dir);
72
73 match mode {
74 DiffMode::Head => {
75 cmd.args(["diff", "--name-only", "HEAD"]);
77 }
78 DiffMode::Staged => {
79 cmd.args(["diff", "--name-only", "--cached"]);
80 }
81 DiffMode::Branch(branch) => {
82 cmd.args(["diff", "--name-only", &format!("{}...HEAD", branch)]);
84 }
85 DiffMode::Commit(sha) => {
86 cmd.args(["diff", "--name-only", sha, "HEAD"]);
87 }
88 }
89
90 let output = cmd.output().map_err(|e| TestxError::IoError {
91 context: "Failed to run git diff".into(),
92 source: e,
93 })?;
94
95 if !output.status.success() {
96 let stderr = String::from_utf8_lossy(&output.stderr);
97 return Err(TestxError::ConfigError {
98 message: format!("git diff failed: {}", stderr.trim()),
99 });
100 }
101
102 let stdout = String::from_utf8_lossy(&output.stdout);
103 let mut files: Vec<PathBuf> = stdout
104 .lines()
105 .filter(|line| !line.is_empty())
106 .map(PathBuf::from)
107 .collect();
108
109 if matches!(mode, DiffMode::Head)
111 && let Ok(untracked) = get_untracked_files(project_dir)
112 {
113 files.extend(untracked);
114 }
115
116 let unique: HashSet<PathBuf> = files.into_iter().collect();
118 let mut result: Vec<PathBuf> = unique.into_iter().collect();
119 result.sort();
120
121 Ok(result)
122}
123
124fn get_untracked_files(project_dir: &Path) -> Result<Vec<PathBuf>> {
126 let output = Command::new("git")
127 .current_dir(project_dir)
128 .args(["ls-files", "--others", "--exclude-standard"])
129 .output()
130 .map_err(|e| TestxError::IoError {
131 context: "Failed to run git ls-files".into(),
132 source: e,
133 })?;
134
135 let stdout = String::from_utf8_lossy(&output.stdout);
136 Ok(stdout
137 .lines()
138 .filter(|l| !l.is_empty())
139 .map(PathBuf::from)
140 .collect())
141}
142
143struct LanguageExtensions {
145 mappings: Vec<(&'static str, &'static [&'static str])>,
146}
147
148impl LanguageExtensions {
149 fn new() -> Self {
150 Self {
151 mappings: vec![
152 ("Rust", &["rs", "toml"]),
153 ("Go", &["go", "mod", "sum"]),
154 ("Python", &["py", "pyi", "cfg", "ini", "toml"]),
155 (
156 "JavaScript",
157 &["js", "jsx", "ts", "tsx", "mjs", "cjs", "json"],
158 ),
159 (
160 "Java",
161 &["java", "kt", "kts", "gradle", "xml", "properties"],
162 ),
163 (
164 "C++",
165 &["cpp", "cc", "cxx", "c", "h", "hpp", "hxx", "cmake"],
166 ),
167 ("Ruby", &["rb", "rake", "gemspec"]),
168 ("Elixir", &["ex", "exs"]),
169 ("PHP", &["php", "xml"]),
170 (".NET", &["cs", "fs", "vb", "csproj", "fsproj", "sln"]),
171 ("Zig", &["zig"]),
172 ],
173 }
174 }
175
176 fn is_relevant_extension(&self, extension: &str) -> bool {
178 self.mappings
179 .iter()
180 .any(|(_, exts)| exts.contains(&extension))
181 }
182
183 fn adapters_for_extension(&self, extension: &str) -> Vec<&'static str> {
185 self.mappings
186 .iter()
187 .filter(|(_, exts)| exts.contains(&extension))
188 .map(|(adapter, _)| *adapter)
189 .collect()
190 }
191}
192
193#[derive(Debug, Clone)]
195pub struct ImpactAnalysis {
196 pub total_changed: usize,
198 pub relevant_files: Vec<PathBuf>,
200 pub irrelevant_files: Vec<PathBuf>,
202 pub affected_adapters: Vec<String>,
204 pub should_run_tests: bool,
206 pub diff_mode: String,
208}
209
210pub fn analyze_impact(project_dir: &Path, mode: &DiffMode) -> Result<ImpactAnalysis> {
212 let changed_files = get_changed_files(project_dir, mode)?;
213 let extensions = LanguageExtensions::new();
214
215 let excluded_prefixes: &[&str] = &[".testx/", ".testx\\"];
217
218 let mut relevant_files = Vec::new();
219 let mut irrelevant_files = Vec::new();
220 let mut affected_set: HashSet<String> = HashSet::new();
221
222 for file in &changed_files {
223 let path_str = file.to_string_lossy();
225 if excluded_prefixes.iter().any(|p| path_str.starts_with(p)) {
226 irrelevant_files.push(file.clone());
227 continue;
228 }
229
230 let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
231
232 if extensions.is_relevant_extension(ext) || is_config_file(file) {
233 relevant_files.push(file.clone());
234 for adapter in extensions.adapters_for_extension(ext) {
235 affected_set.insert(adapter.to_string());
236 }
237 if is_config_file(file) {
239 affected_set.insert("config".to_string());
240 }
241 } else {
242 irrelevant_files.push(file.clone());
243 }
244 }
245
246 let mut affected_adapters: Vec<String> = affected_set.into_iter().collect();
247 affected_adapters.sort();
248
249 let should_run_tests = !relevant_files.is_empty();
250
251 Ok(ImpactAnalysis {
252 total_changed: changed_files.len(),
253 relevant_files,
254 irrelevant_files,
255 affected_adapters,
256 should_run_tests,
257 diff_mode: mode.description(),
258 })
259}
260
261fn is_config_file(path: &Path) -> bool {
263 let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
264
265 matches!(
266 filename,
267 "Cargo.toml"
268 | "Cargo.lock"
269 | "go.mod"
270 | "go.sum"
271 | "package.json"
272 | "package-lock.json"
273 | "yarn.lock"
274 | "pnpm-lock.yaml"
275 | "Gemfile"
276 | "Gemfile.lock"
277 | "requirements.txt"
278 | "setup.py"
279 | "setup.cfg"
280 | "pyproject.toml"
281 | "pom.xml"
282 | "build.gradle"
283 | "build.gradle.kts"
284 | "mix.exs"
285 | "composer.json"
286 | "composer.lock"
287 | "CMakeLists.txt"
288 | "Makefile"
289 | "testx.toml"
290 )
291}
292
293pub fn is_git_repo(project_dir: &Path) -> bool {
295 Command::new("git")
296 .current_dir(project_dir)
297 .args(["rev-parse", "--is-inside-work-tree"])
298 .output()
299 .is_ok_and(|o| o.status.success())
300}
301
302pub fn format_impact(analysis: &ImpactAnalysis) -> String {
304 let mut lines = Vec::new();
305
306 lines.push(format!(
307 "Impact Analysis ({}): {} file(s) changed",
308 analysis.diff_mode, analysis.total_changed
309 ));
310
311 if analysis.relevant_files.is_empty() {
312 lines.push(" No test-relevant files changed — tests can be skipped.".to_string());
313 return lines.join("\n");
314 }
315
316 lines.push(format!(
317 " {} relevant, {} irrelevant",
318 analysis.relevant_files.len(),
319 analysis.irrelevant_files.len()
320 ));
321
322 if !analysis.affected_adapters.is_empty() {
323 lines.push(format!(
324 " Affected: {}",
325 analysis.affected_adapters.join(", ")
326 ));
327 }
328
329 lines.push(" Changed test-relevant files:".to_string());
330 for file in &analysis.relevant_files {
331 lines.push(format!(" {}", file.display()));
332 }
333
334 lines.join("\n")
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn diff_mode_parse_head() {
343 let mode = DiffMode::parse("head").unwrap();
344 assert!(matches!(mode, DiffMode::Head));
345
346 let mode = DiffMode::parse("HEAD").unwrap();
347 assert!(matches!(mode, DiffMode::Head));
348 }
349
350 #[test]
351 fn diff_mode_parse_staged() {
352 let mode = DiffMode::parse("staged").unwrap();
353 assert!(matches!(mode, DiffMode::Staged));
354 }
355
356 #[test]
357 fn diff_mode_parse_branch() {
358 let mode = DiffMode::parse("branch:main").unwrap();
359 match mode {
360 DiffMode::Branch(b) => assert_eq!(b, "main"),
361 _ => panic!("Expected Branch"),
362 }
363 }
364
365 #[test]
366 fn diff_mode_parse_commit() {
367 let mode = DiffMode::parse("commit:abc123").unwrap();
368 match mode {
369 DiffMode::Commit(c) => assert_eq!(c, "abc123"),
370 _ => panic!("Expected Commit"),
371 }
372 }
373
374 #[test]
375 fn diff_mode_parse_errors() {
376 assert!(DiffMode::parse("invalid").is_err());
377 assert!(DiffMode::parse("branch:").is_err());
378 assert!(DiffMode::parse("commit:").is_err());
379 }
380
381 #[test]
382 fn diff_mode_description() {
383 assert_eq!(DiffMode::Head.description(), "uncommitted changes vs HEAD");
384 assert_eq!(DiffMode::Staged.description(), "staged changes");
385 assert_eq!(
386 DiffMode::Branch("main".into()).description(),
387 "changes vs branch 'main'"
388 );
389 assert_eq!(
390 DiffMode::Commit("abc".into()).description(),
391 "changes since commit 'abc'"
392 );
393 }
394
395 #[test]
396 fn language_extensions_rust() {
397 let exts = LanguageExtensions::new();
398 assert!(exts.is_relevant_extension("rs"));
399 assert!(exts.is_relevant_extension("toml"));
400 let adapters = exts.adapters_for_extension("rs");
401 assert!(adapters.contains(&"Rust"));
402 }
403
404 #[test]
405 fn language_extensions_go() {
406 let exts = LanguageExtensions::new();
407 assert!(exts.is_relevant_extension("go"));
408 let adapters = exts.adapters_for_extension("go");
409 assert!(adapters.contains(&"Go"));
410 }
411
412 #[test]
413 fn language_extensions_javascript() {
414 let exts = LanguageExtensions::new();
415 for ext in &["js", "jsx", "ts", "tsx", "mjs", "cjs"] {
416 assert!(exts.is_relevant_extension(ext));
417 let adapters = exts.adapters_for_extension(ext);
418 assert!(adapters.contains(&"JavaScript"));
419 }
420 }
421
422 #[test]
423 fn language_extensions_all_languages() {
424 let exts = LanguageExtensions::new();
425 let test_cases = vec![
426 ("py", "Python"),
427 ("java", "Java"),
428 ("cpp", "C++"),
429 ("rb", "Ruby"),
430 ("ex", "Elixir"),
431 ("php", "PHP"),
432 ("cs", ".NET"),
433 ("zig", "Zig"),
434 ];
435
436 for (ext, adapter) in test_cases {
437 assert!(
438 exts.is_relevant_extension(ext),
439 "Extension {} should be relevant",
440 ext
441 );
442 let adapters = exts.adapters_for_extension(ext);
443 assert!(
444 adapters.contains(&adapter),
445 "Extension {} should map to adapter {}",
446 ext,
447 adapter
448 );
449 }
450 }
451
452 #[test]
453 fn irrelevant_extensions() {
454 let exts = LanguageExtensions::new();
455 assert!(!exts.is_relevant_extension("md"));
456 assert!(!exts.is_relevant_extension("txt"));
457 assert!(!exts.is_relevant_extension("png"));
458 assert!(!exts.is_relevant_extension("yml"));
459 assert!(!exts.is_relevant_extension(""));
460 }
461
462 #[test]
463 fn config_file_detection() {
464 assert!(is_config_file(Path::new("Cargo.toml")));
465 assert!(is_config_file(Path::new("package.json")));
466 assert!(is_config_file(Path::new("go.mod")));
467 assert!(is_config_file(Path::new("requirements.txt")));
468 assert!(is_config_file(Path::new("testx.toml")));
469 assert!(is_config_file(Path::new("pom.xml")));
470 assert!(is_config_file(Path::new("mix.exs")));
471 assert!(is_config_file(Path::new("CMakeLists.txt")));
472
473 assert!(!is_config_file(Path::new("README.md")));
474 assert!(!is_config_file(Path::new("src/main.rs")));
475 assert!(!is_config_file(Path::new("image.png")));
476 }
477
478 #[test]
479 fn format_impact_no_relevant() {
480 let analysis = ImpactAnalysis {
481 total_changed: 3,
482 relevant_files: vec![],
483 irrelevant_files: vec![
484 PathBuf::from("README.md"),
485 PathBuf::from("docs/guide.md"),
486 PathBuf::from(".gitignore"),
487 ],
488 affected_adapters: vec![],
489 should_run_tests: false,
490 diff_mode: "uncommitted changes vs HEAD".to_string(),
491 };
492
493 let output = format_impact(&analysis);
494 assert!(output.contains("3 file(s) changed"));
495 assert!(output.contains("tests can be skipped"));
496 }
497
498 #[test]
499 fn format_impact_with_relevant() {
500 let analysis = ImpactAnalysis {
501 total_changed: 5,
502 relevant_files: vec![PathBuf::from("src/main.rs"), PathBuf::from("src/lib.rs")],
503 irrelevant_files: vec![
504 PathBuf::from("README.md"),
505 PathBuf::from("docs/api.md"),
506 PathBuf::from(".gitignore"),
507 ],
508 affected_adapters: vec!["Rust".to_string()],
509 should_run_tests: true,
510 diff_mode: "changes vs branch 'main'".to_string(),
511 };
512
513 let output = format_impact(&analysis);
514 assert!(output.contains("5 file(s) changed"));
515 assert!(output.contains("2 relevant"));
516 assert!(output.contains("3 irrelevant"));
517 assert!(output.contains("Rust"));
518 assert!(output.contains("src/main.rs"));
519 }
520
521 #[test]
522 fn is_git_repo_not_a_repo() {
523 let dir = tempfile::tempdir().unwrap();
524 assert!(!is_git_repo(dir.path()));
526 }
527
528 #[test]
529 fn impact_analysis_on_non_git_dir() {
530 let dir = tempfile::tempdir().unwrap();
531 let result = analyze_impact(dir.path(), &DiffMode::Head);
532 assert!(result.is_err());
534 }
535}