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};
27
28#[derive(Debug, Args)]
30pub struct ChangeImpactArgs {
31 #[arg(default_value = ".")]
33 pub path: PathBuf,
34
35 #[arg(long, short = 'l')]
37 pub lang: Option<Language>,
38
39 #[arg(long, short = 'F', value_delimiter = ',')]
42 pub files: Vec<PathBuf>,
43
44 #[arg(long, short = 'b')]
46 pub base: Option<String>,
47
48 #[arg(long)]
50 pub staged: bool,
51
52 #[arg(long)]
54 pub uncommitted: bool,
55
56 #[arg(long, short = 'd', default_value = "10")]
59 pub depth: usize,
60
61 #[arg(long, default_value = "true")]
63 pub include_imports: bool,
64
65 #[arg(long, value_delimiter = ',')]
67 pub test_patterns: Vec<String>,
68
69 #[arg(long = "output-format", short = 'o', hide = true)]
72 pub output_format: Option<OutputFormat>,
73
74 #[arg(long, value_enum)]
76 pub runner: Option<RunnerFormat>,
77}
78
79#[derive(Debug, Clone, Copy, clap::ValueEnum)]
81pub enum RunnerFormat {
82 Pytest,
84 PytestK,
86 Jest,
88 GoTest,
90 CargoTest,
92}
93
94impl ChangeImpactArgs {
95 fn determine_detection_method(&self) -> DetectionMethod {
97 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 pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
113 let writer = OutputWriter::new(self.output_format.unwrap_or(format), quiet);
114
115 let language = self
117 .lang
118 .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
119
120 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 let explicit_files = if !self.files.is_empty() {
132 Some(self.files.clone())
133 } else {
134 None
135 };
136
137 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 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 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
180fn format_for_runner(report: &ChangeImpactReport, runner: RunnerFormat) -> String {
182 match runner {
183 RunnerFormat::Pytest => {
184 report
186 .affected_tests
187 .iter()
188 .map(|p| p.display().to_string())
189 .collect::<Vec<_>>()
190 .join(" ")
191 }
192 RunnerFormat::PytestK => {
193 if report.affected_test_functions.is_empty() {
195 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 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 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 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 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 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 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}