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