Skip to main content

cargo_litmus/
cli.rs

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    /// Parse Git-tracked Rust files and write a rkyv index.
33    Index {
34        /// Repository or subdirectory root to index.
35        #[arg(long, default_value = ".")]
36        root: PathBuf,
37
38        /// Index cache output path. Defaults to
39        /// $CARGO_TARGET_DIR/cargo-litmus.rkyv or target/cargo-litmus.rkyv.
40        #[arg(long)]
41        cache: Option<PathBuf>,
42
43        /// Output format for stdout.
44        #[arg(long, value_enum, default_value_t = OutputFormat::Summary)]
45        format: OutputFormat,
46
47        /// Write the full JSON report to this path and print telemetry to
48        /// stdout.
49        #[arg(long)]
50        json_output: Option<PathBuf>,
51    },
52
53    /// Report packages and workspaces affected by changed files.
54    Affected {
55        /// Repository or subdirectory root to analyze.
56        #[arg(long, default_value = ".")]
57        root: PathBuf,
58
59        /// Previous/base rkyv index to compare against. Defaults to
60        /// LITMUS_BASE_CACHE when set.
61        #[arg(long)]
62        base_cache: Option<PathBuf>,
63
64        /// Current rkyv index output path. Defaults to
65        /// $CARGO_TARGET_DIR/cargo-litmus.rkyv or target/cargo-litmus.rkyv.
66        #[arg(long)]
67        current_cache: Option<PathBuf>,
68
69        /// Changed files to analyze.
70        #[arg(long, num_args = 1..)]
71        files: Vec<String>,
72
73        /// Base revision for changed-line discovery.
74        #[arg(long = "base", visible_alias = "base-rev", requires = "head_rev")]
75        base_rev: Option<String>,
76
77        /// Head revision for changed-line discovery.
78        #[arg(long = "head", visible_alias = "head-rev", requires = "base_rev")]
79        head_rev: Option<String>,
80
81        /// Compare the merge-base of this revision and HEAD with HEAD.
82        #[arg(long, conflicts_with_all = ["base_rev", "head_rev", "worktree", "staged"])]
83        merge_base: Option<String>,
84
85        /// Analyze staged, unstaged, deleted, renamed, and untracked changes.
86        #[arg(long, conflicts_with_all = ["base_rev", "head_rev", "merge_base", "staged"])]
87        worktree: bool,
88
89        /// Analyze only changes staged in the Git index.
90        #[arg(long, conflicts_with_all = ["base_rev", "head_rev", "merge_base", "worktree"])]
91        staged: bool,
92
93        /// Read changed files from stdin as lines, a JSON array, or a JSON
94        /// object.
95        #[arg(long)]
96        files_stdin: bool,
97
98        /// Validate selected filters with cargo-nextest. This may compile
99        /// repository code and execute build scripts; use only on trusted code.
100        #[arg(long)]
101        validate_nextest: bool,
102
103        /// Output format for stdout.
104        #[arg(long, value_enum, default_value_t = OutputFormat::Summary)]
105        format: OutputFormat,
106
107        /// Write the full JSON report to this path and print telemetry to
108        /// stdout.
109        #[arg(long)]
110        json_output: Option<PathBuf>,
111    },
112
113    /// Explain why a path or package selects tests and where selection widens.
114    Explain {
115        /// Changed repository-relative path to explain.
116        #[arg(required_unless_present = "package", conflicts_with = "package")]
117        path: Option<String>,
118
119        /// Cargo package whose reverse-dependency selection should be
120        /// explained.
121        #[arg(long, conflicts_with = "path")]
122        package: Option<String>,
123
124        /// Repository or subdirectory root to analyze.
125        #[arg(long, default_value = ".")]
126        root: PathBuf,
127
128        /// Current rkyv index output path.
129        #[arg(long)]
130        current_cache: Option<PathBuf>,
131
132        /// Validate selected filters with cargo-nextest. This may compile
133        /// repository code and execute build scripts; use only on trusted code.
134        #[arg(long)]
135        validate_nextest: bool,
136
137        /// Output format for stdout.
138        #[arg(long, value_enum, default_value_t = OutputFormat::Summary)]
139        format: OutputFormat,
140
141        /// Write the full JSON report to this path and print telemetry to
142        /// stdout.
143        #[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}