Skip to main content

testx/
impact.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use crate::error::{Result, TestxError};
6
7/// Get the list of files changed according to git.
8///
9/// Supports multiple diff modes:
10/// - `head`: Changes in the current working tree vs HEAD (uncommitted changes)
11/// - `staged`: Only staged changes (git diff --cached)
12/// - `branch:name`: Changes vs a specific branch (e.g., `branch:main`)
13/// - `commit:sha`: Changes since a specific commit
14#[derive(Debug, Clone)]
15pub enum DiffMode {
16    /// Uncommitted changes (working tree + staged vs HEAD).
17    Head,
18    /// Only staged changes.
19    Staged,
20    /// Changes compared to a specific branch.
21    Branch(String),
22    /// Changes since a specific commit.
23    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
68/// Get changed files from git diff.
69pub 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            // Show both staged and unstaged changes, plus untracked files
76            cmd.args(["diff", "--name-only", "HEAD"]);
77        }
78        DiffMode::Staged => {
79            cmd.args(["diff", "--name-only", "--cached"]);
80        }
81        DiffMode::Branch(branch) => {
82            // Changes between current HEAD and the merge-base with branch
83            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    // For HEAD mode, also include untracked files
110    if matches!(mode, DiffMode::Head)
111        && let Ok(untracked) = get_untracked_files(project_dir)
112    {
113        files.extend(untracked);
114    }
115
116    // Deduplicate
117    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
124/// Get untracked files (not ignored).
125fn 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
143/// Language extension mapping for determining if changed files affect tests.
144struct 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    /// Check if a file extension is relevant for any adapter.
177    fn is_relevant_extension(&self, extension: &str) -> bool {
178        self.mappings
179            .iter()
180            .any(|(_, exts)| exts.contains(&extension))
181    }
182
183    /// Get the adapters that a file extension belongs to.
184    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/// Result of impact analysis.
194#[derive(Debug, Clone)]
195pub struct ImpactAnalysis {
196    /// Total files changed.
197    pub total_changed: usize,
198    /// Files that are relevant to testing.
199    pub relevant_files: Vec<PathBuf>,
200    /// Files that are not relevant to testing.
201    pub irrelevant_files: Vec<PathBuf>,
202    /// Adapters (languages) that are affected.
203    pub affected_adapters: Vec<String>,
204    /// Whether tests should be run.
205    pub should_run_tests: bool,
206    /// The diff mode used.
207    pub diff_mode: String,
208}
209
210/// Analyze changed files to determine test impact.
211pub 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    // Paths to exclude from impact analysis (build artifacts, cache, etc.)
216    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        // Skip excluded paths
224        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            // Config files affect all adapters
238            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
261/// Check if a file is a project config/build file.
262fn 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
293/// Check if git is available and the project is a git repository.
294pub 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
302/// Format impact analysis for display.
303pub 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        // A fresh tempdir is not a git repo
525        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        // Should error because not a git repo
533        assert!(result.is_err());
534    }
535}