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