1use std::collections::BTreeSet;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use clap::{Parser, Subcommand, ValueEnum};
6use serde::Serialize;
7
8use crate::affected::{
9 AffectedOptions, AffectedReport, compute_affected, compute_affected_validated,
10};
11use crate::changed_files::{
12 ChangeSet, ChangedFileInput, merge_changed_file_inputs, parse_changed_file_inputs,
13};
14use crate::error::{LitmusError, Result};
15use crate::explain::ExplainReport;
16use crate::ferris::CommandFerrisRunner;
17use crate::indexer::{IndexOptions, IndexReport, build_index};
18
19#[derive(Debug, Parser)]
20#[command(
21 author,
22 version,
23 about = "Select affected Rust tests conservatively from Git changes"
24)]
25pub struct Cli {
26 #[command(subcommand)]
27 pub command: Commands,
28}
29
30#[derive(Debug, Subcommand)]
31pub enum Commands {
32 Index {
34 #[arg(long, default_value = ".")]
36 root: PathBuf,
37
38 #[arg(long)]
41 cache: Option<PathBuf>,
42
43 #[arg(long, value_enum, default_value_t = OutputFormat::Summary)]
45 format: OutputFormat,
46
47 #[arg(long)]
50 json_output: Option<PathBuf>,
51 },
52
53 Affected {
55 #[arg(long, default_value = ".")]
57 root: PathBuf,
58
59 #[arg(long)]
62 base_cache: Option<PathBuf>,
63
64 #[arg(long)]
67 current_cache: Option<PathBuf>,
68
69 #[arg(long, num_args = 1..)]
71 files: Vec<String>,
72
73 #[arg(long = "base", visible_alias = "base-rev", requires = "head_rev")]
75 base_rev: Option<String>,
76
77 #[arg(long = "head", visible_alias = "head-rev", requires = "base_rev")]
79 head_rev: Option<String>,
80
81 #[arg(long, conflicts_with_all = ["base_rev", "head_rev", "worktree", "staged"])]
83 merge_base: Option<String>,
84
85 #[arg(long, conflicts_with_all = ["base_rev", "head_rev", "merge_base", "staged"])]
87 worktree: bool,
88
89 #[arg(long, conflicts_with_all = ["base_rev", "head_rev", "merge_base", "worktree"])]
91 staged: bool,
92
93 #[arg(long)]
96 files_stdin: bool,
97
98 #[arg(long)]
101 validate_nextest: bool,
102
103 #[arg(long, value_enum, default_value_t = OutputFormat::Summary)]
105 format: OutputFormat,
106
107 #[arg(long)]
110 json_output: Option<PathBuf>,
111 },
112
113 Explain {
115 #[arg(required_unless_present = "package", conflicts_with = "package")]
117 path: Option<String>,
118
119 #[arg(long, conflicts_with = "path")]
122 package: Option<String>,
123
124 #[arg(long, default_value = ".")]
126 root: PathBuf,
127
128 #[arg(long)]
130 current_cache: Option<PathBuf>,
131
132 #[arg(long)]
135 validate_nextest: bool,
136
137 #[arg(long, value_enum, default_value_t = OutputFormat::Summary)]
139 format: OutputFormat,
140
141 #[arg(long)]
144 json_output: Option<PathBuf>,
145 },
146}
147
148#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
149pub enum OutputFormat {
150 Json,
151 Summary,
152}
153
154pub fn run() -> miette::Result<()> {
155 let cli = Cli::parse();
156 execute(cli).map_err(Into::into)
157}
158
159pub fn execute(cli: Cli) -> Result<()> {
160 match cli.command {
161 Commands::Index {
162 root,
163 cache,
164 format,
165 json_output,
166 } => {
167 let options = IndexOptions {
168 root,
169 cache_path: cache,
170 };
171 let (metadata, summary) = build_index(&options)?;
172
173 let report = IndexReport {
174 summary,
175 metadata: Some(metadata),
176 };
177 write_json_output(json_output.as_deref(), &report)?;
178
179 match (format, json_output.is_some()) {
180 (OutputFormat::Summary, _) | (OutputFormat::Json, true) => {
181 print_index_telemetry(&report.summary)
182 }
183 (OutputFormat::Json, false) => print_json(&report)?,
184 }
185 }
186 Commands::Affected {
187 root,
188 base_cache,
189 current_cache,
190 files,
191 base_rev,
192 head_rev,
193 merge_base,
194 worktree,
195 staged,
196 files_stdin,
197 validate_nextest,
198 format,
199 json_output,
200 } => {
201 let changed_files = collect_changed_files(ChangeInputOptions {
202 root: &root,
203 files,
204 files_stdin,
205 base_rev,
206 head_rev,
207 merge_base,
208 worktree,
209 staged,
210 })?;
211 let options = AffectedOptions {
212 root,
213 files: Vec::new(),
214 changed_files,
215 base_cache: base_cache.or_else(default_base_cache),
216 current_cache,
217 };
218 let report = if validate_nextest {
219 compute_affected_validated(&options, &CommandFerrisRunner)?
220 } else {
221 compute_affected(&options, &CommandFerrisRunner)?
222 };
223 write_json_output(json_output.as_deref(), &report)?;
224
225 match (format, json_output.is_some()) {
226 (OutputFormat::Summary, _) | (OutputFormat::Json, true) => {
227 print_affected_summary(&report)
228 }
229 (OutputFormat::Json, false) => print_json(&report)?,
230 }
231 }
232 Commands::Explain {
233 path,
234 package,
235 root,
236 current_cache,
237 validate_nextest,
238 format,
239 json_output,
240 } => {
241 let report = if let Some(path) = path {
242 let options = AffectedOptions {
243 root,
244 files: Vec::new(),
245 changed_files: vec![ChangedFileInput::path_only(path.clone())],
246 base_cache: None,
247 current_cache,
248 };
249 let affected = if validate_nextest {
250 compute_affected_validated(&options, &CommandFerrisRunner)?
251 } else {
252 compute_affected(&options, &CommandFerrisRunner)?
253 };
254 ExplainReport::for_path(&path, affected)
255 } else {
256 let package = package.ok_or_else(|| LitmusError::InvalidArguments {
257 message: "explain requires a path or --package".to_string(),
258 })?;
259 let (metadata, _) = build_index(&IndexOptions {
260 root,
261 cache_path: current_cache,
262 })?;
263 ExplainReport::for_package(&metadata, &package)?
264 };
265 write_json_output(json_output.as_deref(), &report)?;
266 match (format, json_output.is_some()) {
267 (OutputFormat::Summary, _) | (OutputFormat::Json, true) => {
268 print_explain_summary(&report)
269 }
270 (OutputFormat::Json, false) => print_json(&report)?,
271 }
272 }
273 }
274
275 Ok(())
276}
277
278fn print_explain_summary(report: &ExplainReport) {
279 println!(
280 "cargo-litmus explain schema_version={}",
281 report.schema_version
282 );
283 println!("subject={:?}", report.subject);
284 println!("nextest_validation={:?}", report.nextest_validation);
285 println!("failed_wide={}", report.failed_wide);
286 for chain in &report.selection_chains {
287 println!(
288 "chain={} --{}--> {}",
289 chain.from, chain.relationship, chain.to
290 );
291 }
292 for reason in &report.widening_reasons {
293 println!("widening_reason={reason}");
294 }
295}
296
297fn write_json_output<T: Serialize>(path: Option<&Path>, report: &T) -> Result<()> {
298 let Some(path) = path else {
299 return Ok(());
300 };
301 if let Some(parent) = path.parent() {
302 fs::create_dir_all(parent).map_err(|source| LitmusError::io(parent, source))?;
303 }
304 let rendered = serde_json::to_string_pretty(report)?;
305 fs::write(path, rendered).map_err(|source| LitmusError::io(path, source))
306}
307
308struct ChangeInputOptions<'a> {
309 root: &'a Path,
310 files: Vec<String>,
311 files_stdin: bool,
312 base_rev: Option<String>,
313 head_rev: Option<String>,
314 merge_base: Option<String>,
315 worktree: bool,
316 staged: bool,
317}
318
319fn collect_changed_files(options: ChangeInputOptions<'_>) -> Result<Vec<ChangedFileInput>> {
320 let ChangeInputOptions {
321 root,
322 files,
323 files_stdin,
324 base_rev,
325 head_rev,
326 merge_base,
327 worktree,
328 staged,
329 } = options;
330 let mut changed_files = files
331 .into_iter()
332 .map(ChangedFileInput::path_only)
333 .collect::<Vec<_>>();
334 if files_stdin {
335 let mut raw = String::new();
336 std::io::Read::read_to_string(&mut std::io::stdin(), &mut raw)
337 .map_err(|source| crate::error::LitmusError::io("stdin", source))?;
338 changed_files.extend(parse_changed_file_inputs(&raw));
339 }
340 if let (Some(base_rev), Some(head_rev)) = (base_rev, head_rev) {
341 let revision_files = ChangeSet::revisions(root, &base_rev, &head_rev)?.files;
342 changed_files = merge_revision_changed_files(changed_files, revision_files);
343 } else if let Some(upstream) = merge_base {
344 changed_files.extend(ChangeSet::merge_base(root, &upstream)?.files);
345 } else if worktree {
346 changed_files.extend(ChangeSet::worktree(root)?.files);
347 } else if staged {
348 changed_files.extend(ChangeSet::staged(root)?.files);
349 }
350
351 Ok(merge_changed_file_inputs(changed_files))
352}
353
354fn merge_revision_changed_files(
355 explicit_files: Vec<ChangedFileInput>,
356 revision_files: Vec<ChangedFileInput>,
357) -> Vec<ChangedFileInput> {
358 if explicit_files.is_empty() {
359 return revision_files;
360 }
361
362 let explicit_paths = explicit_files
363 .iter()
364 .map(|file| file.path.clone())
365 .collect::<BTreeSet<_>>();
366 explicit_files
367 .into_iter()
368 .chain(
369 revision_files
370 .into_iter()
371 .filter(|file| explicit_paths.contains(&file.path)),
372 )
373 .collect()
374}
375
376fn default_base_cache() -> Option<PathBuf> {
377 std::env::var_os("LITMUS_BASE_CACHE")
378 .filter(|value| !value.is_empty())
379 .map(PathBuf::from)
380}
381
382fn print_index_telemetry(summary: &impl Serialize) {
383 let Ok(summary) = serde_json::to_value(summary) else {
384 return;
385 };
386 println!("cargo-litmus indexed Rust source");
387 for key in [
388 "files_parsed",
389 "items_indexed",
390 "packages_indexed",
391 "targets_indexed",
392 "package_dependencies",
393 "edges",
394 "parse_failures",
395 "source_bytes",
396 "cache_bytes",
397 "elapsed_ms",
398 "cache_path",
399 "git_head",
400 "rust_file_list_hash",
401 "tracked_file_list_hash",
402 "cargo_metadata_hash",
403 ] {
404 if let Some(value) = summary.get(key) {
405 println!("{key}: {}", value.as_str().unwrap_or(&value.to_string()));
406 }
407 }
408}
409
410fn print_affected_summary(report: &AffectedReport) {
411 println!("cargo-litmus affected report");
412 println!("schema_version: {}", report.schema_version);
413 println!(
414 "nextest_validation: {}",
415 summary_value(&report.nextest_validation)
416 );
417 println!("cache_status: {}", summary_value(&report.cache_status));
418 println!("failed_wide: {}", report.failed_wide);
419 println!("selection_reason: {}", report.selection_reason);
420 println!("changed_files: {}", report.changed_files.len());
421 println!("changed_items: {}", report.changed_items.len());
422 println!("unknown_files: {}", report.unknown_files.len());
423 println!("affected_crates: {}", report.affected_crates.len());
424 println!(
425 "affected_test_crates: {}",
426 report.affected_test_crates.len()
427 );
428 println!("test_failed_wide: {}", report.test_failed_wide);
429 println!("test_selection_reason: {}", report.test_selection_reason);
430 let missing_test_dependents = report
431 .test_selection_checks
432 .iter()
433 .map(|check| check.missing_dependents.len())
434 .sum::<usize>();
435 println!("missing_test_dependents: {missing_test_dependents}");
436 for check in &report.test_selection_checks {
437 println!(
438 "test_selection_check: {}/{} result={} expected={} selected={} missing={}",
439 check.changed_package.workspace,
440 check.changed_package.name,
441 check.result,
442 check.expected_dependents.len(),
443 check.selected_dependents.len(),
444 check.missing_dependents.len()
445 );
446 }
447 let widened_module_commands = report
448 .nextest_commands
449 .iter()
450 .filter(|command| command.widened_from == Some(crate::affected::NextestCommandMode::Module))
451 .count();
452 println!("widened_module_commands: {widened_module_commands}");
453 println!(
454 "module_test_commands: {}",
455 report
456 .nextest_commands
457 .iter()
458 .filter(|command| { command.mode == crate::affected::NextestCommandMode::Module })
459 .count()
460 );
461 println!(
462 "test_target_commands: {}",
463 report
464 .nextest_commands
465 .iter()
466 .filter(|command| { command.mode == crate::affected::NextestCommandMode::TestTarget })
467 .count()
468 );
469 println!(
470 "package_test_commands: {}",
471 report
472 .nextest_commands
473 .iter()
474 .filter(|command| { command.mode == crate::affected::NextestCommandMode::Package })
475 .count()
476 );
477 println!(
478 "workspace_wide_commands: {}",
479 report
480 .nextest_commands
481 .iter()
482 .filter(|command| {
483 command.mode == crate::affected::NextestCommandMode::WorkspaceWide
484 })
485 .count()
486 );
487 let validated_test_count = report
488 .nextest_commands
489 .iter()
490 .map(|command| command.matched_tests.len())
491 .sum::<usize>();
492 println!("validated_test_count: {validated_test_count}");
493 if !report.selected_test_packages.is_empty() {
494 let selected = report
495 .selected_test_packages
496 .iter()
497 .map(|package| format!("{}/{}: {}", package.workspace, package.name, package.reason))
498 .collect::<Vec<_>>()
499 .join("; ");
500 println!("selected_test_packages: {selected}");
501 }
502 println!("affected_workspaces: {}", report.affected_workspaces.len());
503 let workspace_failed_wide = report
504 .workspace_selections
505 .iter()
506 .filter(|selection| selection.failed_wide)
507 .count();
508 println!("workspace_failed_wide: {workspace_failed_wide}");
509 let test_workspace_failed_wide = report
510 .test_workspace_selections
511 .iter()
512 .filter(|selection| selection.failed_wide)
513 .count();
514 println!("test_workspace_failed_wide: {test_workspace_failed_wide}");
515 let affected_workspace_names = report
516 .affected_workspaces
517 .iter()
518 .map(|workspace| workspace.name.as_str())
519 .collect::<Vec<_>>()
520 .join(",");
521 println!("affected_workspace_names: {affected_workspace_names}");
522 if let Some(summary) = &report.current_index {
523 println!("index_files: {}", summary.files_parsed);
524 println!("index_items: {}", summary.items_indexed);
525 println!("index_packages: {}", summary.packages_indexed);
526 println!("index_targets: {}", summary.targets_indexed);
527 println!(
528 "index_package_dependencies: {}",
529 summary.package_dependencies
530 );
531 println!("index_edges: {}", summary.edges);
532 println!("index_source_bytes: {}", summary.source_bytes);
533 println!("index_cache_bytes: {}", summary.cache_bytes);
534 println!("index_elapsed_ms: {}", summary.elapsed_ms);
535 println!("index_cache_path: {}", summary.cache_path);
536 println!("index_mode: {}", summary_value(&summary.index_mode));
537 println!("index_files_reused: {}", summary.files_reused);
538 println!("index_files_reparsed: {}", summary.files_reparsed);
539 println!("index_files_removed: {}", summary.files_removed);
540 println!("index_files_added: {}", summary.files_added);
541 }
542}
543
544fn summary_value(value: &impl Serialize) -> String {
545 serde_json::to_value(value)
546 .ok()
547 .and_then(|value| value.as_str().map(str::to_owned))
548 .unwrap_or_else(|| "unknown".to_owned())
549}
550
551fn print_json<T: Serialize>(report: &T) -> Result<()> {
552 let rendered = serde_json::to_string_pretty(report)?;
553 println!("{rendered}");
554 Ok(())
555}
556
557#[cfg(test)]
558mod tests {
559 use clap::Parser;
560
561 use super::*;
562
563 #[test]
564 fn parses_index_command() {
565 let cli = Cli::parse_from([
566 "cargo-litmus",
567 "index",
568 "--root",
569 "crates/app",
570 "--format",
571 "json",
572 "--cache",
573 "target/base.rkyv",
574 "--json-output",
575 "target/litmus-index.json",
576 ]);
577
578 let Commands::Index {
579 root,
580 cache,
581 format,
582 json_output,
583 ..
584 } = cli.command
585 else {
586 panic!("expected index command");
587 };
588 assert_eq!(root, PathBuf::from("crates/app"));
589 assert_eq!(cache, Some(PathBuf::from("target/base.rkyv")));
590 assert_eq!(format, OutputFormat::Json);
591 assert_eq!(json_output, Some(PathBuf::from("target/litmus-index.json")));
592 }
593
594 #[test]
595 fn parses_affected_command() {
596 let cli = Cli::parse_from([
597 "cargo-litmus",
598 "affected",
599 "--root",
600 "crates/app",
601 "--base-rev",
602 "HEAD~1",
603 "--head-rev",
604 "HEAD",
605 "--base-cache",
606 "target/base.rkyv",
607 "--current-cache",
608 "target/current.rkyv",
609 "--files",
610 "src/lib.rs",
611 "src/main.rs",
612 "--files-stdin",
613 "--format",
614 "json",
615 "--json-output",
616 "target/litmus-affected.json",
617 ]);
618
619 let Commands::Affected {
620 root,
621 files,
622 base_rev,
623 head_rev,
624 files_stdin,
625 format,
626 json_output,
627 base_cache,
628 current_cache,
629 ..
630 } = cli.command
631 else {
632 panic!("expected affected command");
633 };
634 assert_eq!(root, PathBuf::from("crates/app"));
635 assert_eq!(files, vec!["src/lib.rs", "src/main.rs"]);
636 assert_eq!(base_rev, Some("HEAD~1".to_string()));
637 assert_eq!(head_rev, Some("HEAD".to_string()));
638 assert!(files_stdin);
639 assert_eq!(format, OutputFormat::Json);
640 assert_eq!(
641 json_output,
642 Some(PathBuf::from("target/litmus-affected.json"))
643 );
644 assert_eq!(base_cache, Some(PathBuf::from("target/base.rkyv")));
645 assert_eq!(current_cache, Some(PathBuf::from("target/current.rkyv")));
646 }
647
648 #[test]
649 fn explicit_changed_files_keep_only_matching_revision_ranges() {
650 let merged = merge_changed_file_inputs(merge_revision_changed_files(
651 vec![ChangedFileInput::path_only("src/lib.rs")],
652 vec![
653 ChangedFileInput {
654 path: "src/lib.rs".to_string(),
655 ranges: vec![crate::changed_files::ChangedLineRange {
656 start_line: 4,
657 end_line: 6,
658 }],
659 },
660 ChangedFileInput {
661 path: ".github/scripts/setup_ci_matrix.py".to_string(),
662 ranges: vec![crate::changed_files::ChangedLineRange {
663 start_line: 10,
664 end_line: 12,
665 }],
666 },
667 ],
668 ));
669
670 assert_eq!(merged.len(), 1);
671 assert_eq!(merged[0].path, "src/lib.rs");
672 assert_eq!(merged[0].ranges.len(), 1);
673 assert_eq!(merged[0].ranges[0].start_line, 4);
674 assert_eq!(merged[0].ranges[0].end_line, 6);
675 }
676
677 #[test]
678 fn rev_only_changed_files_use_full_revision_diff() {
679 let merged = merge_revision_changed_files(
680 Vec::new(),
681 vec![
682 ChangedFileInput::path_only("src/lib.rs"),
683 ChangedFileInput::path_only(".github/scripts/setup_ci_matrix.py"),
684 ],
685 );
686
687 assert_eq!(
688 merged.into_iter().map(|file| file.path).collect::<Vec<_>>(),
689 vec!["src/lib.rs", ".github/scripts/setup_ci_matrix.py"]
690 );
691 }
692
693 #[test]
694 fn parses_first_class_change_modes() {
695 for flag in ["--worktree", "--staged"] {
696 let cli = Cli::try_parse_from(["cargo-litmus", "affected", flag]).unwrap();
697 let Commands::Affected {
698 worktree, staged, ..
699 } = cli.command
700 else {
701 panic!("expected affected command");
702 };
703 assert_eq!(worktree, flag == "--worktree");
704 assert_eq!(staged, flag == "--staged");
705 }
706 let cli = Cli::try_parse_from(["cargo-litmus", "affected", "--merge-base", "origin/main"])
707 .unwrap();
708 let Commands::Affected { merge_base, .. } = cli.command else {
709 panic!("expected affected command");
710 };
711 assert_eq!(merge_base.as_deref(), Some("origin/main"));
712 }
713
714 #[test]
715 fn parses_explain_path_and_package() {
716 let path = Cli::try_parse_from(["cargo-litmus", "explain", "src/lib.rs"]).unwrap();
717 assert!(matches!(
718 path.command,
719 Commands::Explain { path: Some(path), package: None, .. } if path == "src/lib.rs"
720 ));
721 let package = Cli::try_parse_from([
722 "cargo-litmus",
723 "explain",
724 "--package",
725 "cargo-litmus",
726 "--format",
727 "json",
728 ])
729 .unwrap();
730 assert!(matches!(
731 package.command,
732 Commands::Explain { path: None, package: Some(package), format: OutputFormat::Json, .. }
733 if package == "cargo-litmus"
734 ));
735 }
736}