Skip to main content

tldr_cli/commands/
change_impact.rs

1//! Change Impact command - Find tests affected by code changes
2//!
3//! Wires tldr-core::change_impact to the CLI (Session 6 Phases 1-5).
4//!
5//! # Detection Methods
6//! - `--files` - Explicit file list
7//! - `--base <branch>` - Git diff against base branch (for PRs)
8//! - `--staged` - Only staged files
9//! - `--uncommitted` - Staged + unstaged (default git mode)
10//! - Default: git diff HEAD
11//!
12//! # Output Formats
13//! - JSON (default): Full report structure
14//! - Text: Human-readable summary
15//! - Runner formats: pytest, pytest-k, jest, go-test, cargo-test
16
17use std::path::PathBuf;
18
19use anyhow::Result;
20use clap::Args;
21
22use tldr_core::{change_impact_extended, ChangeImpactReport, DetectionMethod, Language};
23
24use crate::output::{format_change_impact_text, OutputFormat, OutputWriter};
25
26/// Find tests affected by code changes
27#[derive(Debug, Args)]
28pub struct ChangeImpactArgs {
29    /// Project root directory (default: current directory)
30    #[arg(default_value = ".")]
31    pub path: PathBuf,
32
33    /// Programming language (auto-detect if not specified)
34    #[arg(long, short = 'l')]
35    pub lang: Option<Language>,
36
37    // === Change Detection ===
38    /// Explicit list of changed files (comma-separated)
39    #[arg(long, short = 'F', value_delimiter = ',')]
40    pub files: Vec<PathBuf>,
41
42    /// Git base branch for diff (e.g., "origin/main" for PR workflow)
43    #[arg(long, short = 'b')]
44    pub base: Option<String>,
45
46    /// Only consider staged files (pre-commit workflow)
47    #[arg(long)]
48    pub staged: bool,
49
50    /// Consider all uncommitted changes (staged + unstaged)
51    #[arg(long)]
52    pub uncommitted: bool,
53
54    // === Analysis Options ===
55    /// Maximum call graph traversal depth
56    #[arg(long, short = 'd', default_value = "10")]
57    pub depth: usize,
58
59    /// Include import graph in analysis (not just call graph)
60    #[arg(long, default_value = "true")]
61    pub include_imports: bool,
62
63    /// Custom test file patterns (comma-separated globs)
64    #[arg(long, value_delimiter = ',')]
65    pub test_patterns: Vec<String>,
66
67    // === Output Options ===
68    /// Output format override (backwards compatibility, prefer global --format/-f)
69    #[arg(long = "output-format", short = 'o', hide = true)]
70    pub output_format: Option<OutputFormat>,
71
72    /// Output format for test runner integration
73    #[arg(long, value_enum)]
74    pub runner: Option<RunnerFormat>,
75}
76
77/// Test runner output formats
78#[derive(Debug, Clone, Copy, clap::ValueEnum)]
79pub enum RunnerFormat {
80    /// pytest: space-separated test files
81    Pytest,
82    /// pytest with -k: pytest test_file.py::TestClass::test_func
83    PytestK,
84    /// jest --findRelatedTests format
85    Jest,
86    /// go test -run regex
87    GoTest,
88    /// cargo test filter
89    CargoTest,
90}
91
92impl ChangeImpactArgs {
93    /// Determine detection method based on CLI flags
94    fn determine_detection_method(&self) -> DetectionMethod {
95        // Priority: explicit files > --base > --staged > --uncommitted > HEAD
96        if !self.files.is_empty() {
97            DetectionMethod::Explicit
98        } else if let Some(base) = &self.base {
99            DetectionMethod::GitBase { base: base.clone() }
100        } else if self.staged {
101            DetectionMethod::GitStaged
102        } else if self.uncommitted {
103            DetectionMethod::GitUncommitted
104        } else {
105            DetectionMethod::GitHead
106        }
107    }
108
109    /// Run the change-impact command
110    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
111        let writer = OutputWriter::new(self.output_format.unwrap_or(format), quiet);
112
113        // Determine language (auto-detect from directory, default to Python)
114        let language = self
115            .lang
116            .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
117
118        // Determine detection method based on flags
119        let detection = self.determine_detection_method();
120
121        writer.progress(&format!(
122            "Detecting changes via {} for {:?} in {}...",
123            detection,
124            language,
125            self.path.display()
126        ));
127
128        // Prepare explicit files if provided
129        let explicit_files = if !self.files.is_empty() {
130            Some(self.files.clone())
131        } else {
132            None
133        };
134
135        // Call core change_impact_extended function
136        let report = change_impact_extended(
137            &self.path,
138            detection,
139            language,
140            self.depth,
141            self.include_imports,
142            &self.test_patterns,
143            explicit_files,
144        )?;
145
146        // Output based on format/runner
147        if let Some(runner) = self.runner {
148            let runner_output = format_for_runner(&report, runner);
149            println!("{}", runner_output);
150        } else if writer.is_text() {
151            let text = format_change_impact_text(&report);
152            writer.write_text(&text)?;
153        } else {
154            writer.write(&report)?;
155        }
156
157        Ok(())
158    }
159}
160
161/// Format report for specific test runner
162fn format_for_runner(report: &ChangeImpactReport, runner: RunnerFormat) -> String {
163    match runner {
164        RunnerFormat::Pytest => {
165            // Space-separated test file paths
166            report
167                .affected_tests
168                .iter()
169                .map(|p| p.display().to_string())
170                .collect::<Vec<_>>()
171                .join(" ")
172        }
173        RunnerFormat::PytestK => {
174            // pytest file::class::function format
175            if report.affected_test_functions.is_empty() {
176                // Fall back to file-level if no function extraction
177                report
178                    .affected_tests
179                    .iter()
180                    .map(|p| p.display().to_string())
181                    .collect::<Vec<_>>()
182                    .join(" ")
183            } else {
184                report
185                    .affected_test_functions
186                    .iter()
187                    .map(|tf| {
188                        if let Some(ref class) = tf.class {
189                            format!("{}::{}::{}", tf.file.display(), class, tf.function)
190                        } else {
191                            format!("{}::{}", tf.file.display(), tf.function)
192                        }
193                    })
194                    .collect::<Vec<_>>()
195                    .join(" ")
196            }
197        }
198        RunnerFormat::Jest => {
199            // --findRelatedTests format (uses changed files, not test files)
200            if report.changed_files.is_empty() {
201                String::new()
202            } else {
203                format!(
204                    "--findRelatedTests {}",
205                    report
206                        .changed_files
207                        .iter()
208                        .map(|p| p.display().to_string())
209                        .collect::<Vec<_>>()
210                        .join(" ")
211                )
212            }
213        }
214        RunnerFormat::GoTest => {
215            // go test -run "TestA|TestB" format
216            // Extract test function names from affected_functions that look like tests
217            let test_names: Vec<String> = report
218                .affected_functions
219                .iter()
220                .filter(|f| f.name.starts_with("Test"))
221                .map(|f| f.name.clone())
222                .collect();
223
224            if test_names.is_empty() {
225                String::new()
226            } else {
227                format!("-run \"{}\"", test_names.join("|"))
228            }
229        }
230        RunnerFormat::CargoTest => {
231            // cargo test filter names (test function names)
232            let test_names: Vec<String> = report
233                .affected_functions
234                .iter()
235                .filter(|f| f.name.starts_with("test_"))
236                .map(|f| f.name.clone())
237                .collect();
238
239            test_names.join(" ")
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    fn make_args(
249        base: Option<String>,
250        staged: bool,
251        uncommitted: bool,
252        files: Vec<PathBuf>,
253    ) -> ChangeImpactArgs {
254        ChangeImpactArgs {
255            path: PathBuf::from("."),
256            lang: None,
257            files,
258            base,
259            staged,
260            uncommitted,
261            depth: 10,
262            include_imports: true,
263            test_patterns: vec![],
264            output_format: None,
265            runner: None,
266        }
267    }
268
269    #[test]
270    fn test_args_default_path() {
271        let args = make_args(None, false, false, vec![]);
272        assert_eq!(args.path, PathBuf::from("."));
273    }
274
275    #[test]
276    fn test_args_with_explicit_files() {
277        let args = make_args(
278            None,
279            false,
280            false,
281            vec![PathBuf::from("auth.py"), PathBuf::from("utils.py")],
282        );
283        assert_eq!(args.files.len(), 2);
284    }
285
286    #[test]
287    fn test_detection_method_priority_explicit() {
288        // Explicit files take highest priority
289        let args = make_args(
290            Some("main".to_string()),
291            true,
292            true,
293            vec![PathBuf::from("file.py")],
294        );
295        assert_eq!(args.determine_detection_method(), DetectionMethod::Explicit);
296    }
297
298    #[test]
299    fn test_detection_method_priority_base() {
300        // --base takes priority over staged/uncommitted
301        let args = make_args(Some("origin/main".to_string()), true, true, vec![]);
302        match args.determine_detection_method() {
303            DetectionMethod::GitBase { base } => assert_eq!(base, "origin/main"),
304            _ => panic!("Expected GitBase"),
305        }
306    }
307
308    #[test]
309    fn test_detection_method_priority_staged() {
310        // --staged takes priority over --uncommitted
311        let args = make_args(None, true, true, vec![]);
312        assert_eq!(
313            args.determine_detection_method(),
314            DetectionMethod::GitStaged
315        );
316    }
317
318    #[test]
319    fn test_detection_method_priority_uncommitted() {
320        let args = make_args(None, false, true, vec![]);
321        assert_eq!(
322            args.determine_detection_method(),
323            DetectionMethod::GitUncommitted
324        );
325    }
326
327    #[test]
328    fn test_detection_method_default_head() {
329        let args = make_args(None, false, false, vec![]);
330        assert_eq!(args.determine_detection_method(), DetectionMethod::GitHead);
331    }
332
333    #[test]
334    fn test_format_pytest() {
335        let report = ChangeImpactReport {
336            changed_files: vec![PathBuf::from("src/auth.py")],
337            affected_tests: vec![
338                PathBuf::from("tests/test_auth.py"),
339                PathBuf::from("tests/test_utils.py"),
340            ],
341            affected_test_functions: vec![],
342            affected_functions: vec![],
343            detection_method: "explicit".to_string(),
344            metadata: None,
345        };
346
347        let output = format_for_runner(&report, RunnerFormat::Pytest);
348        assert_eq!(output, "tests/test_auth.py tests/test_utils.py");
349    }
350
351    #[test]
352    fn test_format_jest() {
353        let report = ChangeImpactReport {
354            changed_files: vec![PathBuf::from("src/auth.ts"), PathBuf::from("src/utils.ts")],
355            affected_tests: vec![],
356            affected_test_functions: vec![],
357            affected_functions: vec![],
358            detection_method: "explicit".to_string(),
359            metadata: None,
360        };
361
362        let output = format_for_runner(&report, RunnerFormat::Jest);
363        assert_eq!(output, "--findRelatedTests src/auth.ts src/utils.ts");
364    }
365
366    #[test]
367    fn test_format_pytest_k_with_functions() {
368        use tldr_core::TestFunction;
369
370        let report = ChangeImpactReport {
371            changed_files: vec![PathBuf::from("src/auth.py")],
372            affected_tests: vec![PathBuf::from("tests/test_auth.py")],
373            affected_test_functions: vec![
374                TestFunction {
375                    file: PathBuf::from("tests/test_auth.py"),
376                    function: "test_login".to_string(),
377                    class: Some("TestAuth".to_string()),
378                    line: 10,
379                },
380                TestFunction {
381                    file: PathBuf::from("tests/test_auth.py"),
382                    function: "test_logout".to_string(),
383                    class: None,
384                    line: 20,
385                },
386            ],
387            affected_functions: vec![],
388            detection_method: "explicit".to_string(),
389            metadata: None,
390        };
391
392        let output = format_for_runner(&report, RunnerFormat::PytestK);
393        assert!(output.contains("tests/test_auth.py::TestAuth::test_login"));
394        assert!(output.contains("tests/test_auth.py::test_logout"));
395    }
396}