1use 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#[derive(Debug, Args)]
31pub struct ChangeImpactArgs {
32 #[arg(default_value = ".")]
34 pub path: PathBuf,
35
36 #[arg(long, short = 'l')]
38 pub lang: Option<Language>,
39
40 #[arg(long, short = 'F', value_delimiter = ',')]
43 pub files: Vec<PathBuf>,
44
45 #[arg(long, short = 'b')]
47 pub base: Option<String>,
48
49 #[arg(long)]
51 pub staged: bool,
52
53 #[arg(long)]
55 pub uncommitted: bool,
56
57 #[arg(long, short = 'd', default_value = "10")]
60 pub depth: usize,
61
62 #[arg(long, default_value = "true")]
64 pub include_imports: bool,
65
66 #[arg(long, value_delimiter = ',')]
68 pub test_patterns: Vec<String>,
69
70 #[arg(long = "output-format", short = 'o', hide = true)]
73 pub output_format: Option<OutputFormat>,
74
75 #[arg(long, value_enum)]
77 pub runner: Option<RunnerFormat>,
78}
79
80#[derive(Debug, Clone, Copy, clap::ValueEnum)]
82pub enum RunnerFormat {
83 Pytest,
85 PytestK,
87 Jest,
89 GoTest,
91 CargoTest,
93}
94
95impl ChangeImpactArgs {
96 fn determine_detection_method(&self) -> DetectionMethod {
98 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 pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
114 let writer = OutputWriter::new(self.output_format.unwrap_or(format), quiet);
115
116 require_directory(&self.path, "change-impact")?;
120
121 let language = self
123 .lang
124 .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
125
126 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 let explicit_files = if !self.files.is_empty() {
138 Some(self.files.clone())
139 } else {
140 None
141 };
142
143 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 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 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
186fn format_for_runner(report: &ChangeImpactReport, runner: RunnerFormat) -> String {
188 match runner {
189 RunnerFormat::Pytest => {
190 report
192 .affected_tests
193 .iter()
194 .map(|p| p.display().to_string())
195 .collect::<Vec<_>>()
196 .join(" ")
197 }
198 RunnerFormat::PytestK => {
199 if report.affected_test_functions.is_empty() {
201 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 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 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 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 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 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 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}