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