1use 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#[derive(Debug, Args)]
28pub struct ChangeImpactArgs {
29 #[arg(default_value = ".")]
31 pub path: PathBuf,
32
33 #[arg(long, short = 'l')]
35 pub lang: Option<Language>,
36
37 #[arg(long, short = 'F', value_delimiter = ',')]
40 pub files: Vec<PathBuf>,
41
42 #[arg(long, short = 'b')]
44 pub base: Option<String>,
45
46 #[arg(long)]
48 pub staged: bool,
49
50 #[arg(long)]
52 pub uncommitted: bool,
53
54 #[arg(long, short = 'd', default_value = "10")]
57 pub depth: usize,
58
59 #[arg(long, default_value = "true")]
61 pub include_imports: bool,
62
63 #[arg(long, value_delimiter = ',')]
65 pub test_patterns: Vec<String>,
66
67 #[arg(long = "output-format", short = 'o', hide = true)]
70 pub output_format: Option<OutputFormat>,
71
72 #[arg(long, value_enum)]
74 pub runner: Option<RunnerFormat>,
75}
76
77#[derive(Debug, Clone, Copy, clap::ValueEnum)]
79pub enum RunnerFormat {
80 Pytest,
82 PytestK,
84 Jest,
86 GoTest,
88 CargoTest,
90}
91
92impl ChangeImpactArgs {
93 fn determine_detection_method(&self) -> DetectionMethod {
95 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 pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
111 let writer = OutputWriter::new(self.output_format.unwrap_or(format), quiet);
112
113 let language = self
115 .lang
116 .unwrap_or_else(|| Language::from_directory(&self.path).unwrap_or(Language::Python));
117
118 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 let explicit_files = if !self.files.is_empty() {
130 Some(self.files.clone())
131 } else {
132 None
133 };
134
135 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 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
161fn format_for_runner(report: &ChangeImpactReport, runner: RunnerFormat) -> String {
163 match runner {
164 RunnerFormat::Pytest => {
165 report
167 .affected_tests
168 .iter()
169 .map(|p| p.display().to_string())
170 .collect::<Vec<_>>()
171 .join(" ")
172 }
173 RunnerFormat::PytestK => {
174 if report.affected_test_functions.is_empty() {
176 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 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 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 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 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 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 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}