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            // Validate branch name doesn't start with '-' to prevent flag injection
83            if branch.starts_with('-') {
84                return Err(TestxError::ConfigError {
85                    message: format!("Invalid branch name '{}': must not start with '-'", branch),
86                });
87            }
88            // Changes between current HEAD and the merge-base with branch
89            cmd.args(["diff", "--name-only", &format!("{}...HEAD", branch)]);
90        }
91        DiffMode::Commit(sha) => {
92            // Validate SHA doesn't start with '-' to prevent flag injection
93            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        // On a repo with no commits yet, `git diff HEAD` fails with
114        // "fatal: ambiguous argument 'HEAD'". Fall back to listing all
115        // tracked + untracked files so impact analysis still works on
116        // brand-new repositories.
117        if matches!(mode, DiffMode::Head) && stderr.contains("ambiguous argument 'HEAD'") {
118            // Return all files in the worktree (tracked + untracked)
119            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    // For HEAD mode, also include untracked files
151    if matches!(mode, DiffMode::Head)
152        && let Ok(untracked) = get_untracked_files(project_dir)
153    {
154        files.extend(untracked);
155    }
156
157    // Deduplicate
158    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
165/// Get untracked files (not ignored).
166fn 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
184/// Language extension mapping for determining if changed files affect tests.
185struct 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    /// Check if a file extension is relevant for any adapter.
218    fn is_relevant_extension(&self, extension: &str) -> bool {
219        self.mappings
220            .iter()
221            .any(|(_, exts)| exts.contains(&extension))
222    }
223
224    /// Get the adapters that a file extension belongs to.
225    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/// Result of impact analysis.
235#[derive(Debug, Clone)]
236pub struct ImpactAnalysis {
237    /// Total files changed.
238    pub total_changed: usize,
239    /// Files that are relevant to testing.
240    pub relevant_files: Vec<PathBuf>,
241    /// Files that are not relevant to testing.
242    pub irrelevant_files: Vec<PathBuf>,
243    /// Adapters (languages) that are affected.
244    pub affected_adapters: Vec<String>,
245    /// Whether tests should be run.
246    pub should_run_tests: bool,
247    /// The diff mode used.
248    pub diff_mode: String,
249}
250
251/// Analyze changed files to determine test impact.
252pub 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    // Paths to exclude from impact analysis (build artifacts, cache, etc.)
257    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        // Skip excluded paths
265        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            // Config files affect all adapters
279            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
302/// Check if a file is a project config/build file.
303fn 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
334/// Check if git is available and the project is a git repository.
335pub 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
343/// Format impact analysis for display.
344pub 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        // A fresh tempdir is not a git repo
566        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        // Should error because not a git repo
574        assert!(result.is_err());
575    }
576}