Skip to main content

blast_radius/
cli.rs

1use std::io::Read;
2use std::path::PathBuf;
3use std::sync::OnceLock;
4
5use clap::error::ErrorKind;
6use clap::{Parser, ValueEnum};
7use clap_complete::Shell;
8
9use crate::graph::RiskTier;
10
11/// `-V` prints the plain version; `--version` adds the language adapters this
12/// binary was compiled with, since feature-gated builds differ (prebuilt
13/// binaries ship everything; a default `cargo install` is JS/TS only).
14fn long_version() -> &'static str {
15    static VERSION: OnceLock<String> = OnceLock::new();
16    VERSION.get_or_init(|| {
17        #[allow(unused_mut)]
18        let mut languages = vec!["javascript/typescript"];
19        #[cfg(feature = "python")]
20        languages.push("python");
21        #[cfg(feature = "rust")]
22        languages.push("rust");
23        #[cfg(feature = "vue")]
24        languages.push("vue");
25        #[cfg(feature = "svelte")]
26        languages.push("svelte");
27        format!(
28            "{}\nlanguages: {}",
29            env!("CARGO_PKG_VERSION"),
30            languages.join(", ")
31        )
32    })
33}
34
35#[derive(Debug, Clone, Parser)]
36#[command(name = "blast-radius")]
37#[command(
38    version,
39    long_version = long_version(),
40    about = "Estimate the transitive blast radius of frontend code changes"
41)]
42pub struct Cli {
43    #[command(subcommand)]
44    pub command: Command,
45
46    #[arg(long, global = true, default_value = ".")]
47    pub repo_root: PathBuf,
48
49    #[arg(long, global = true, value_enum, default_value_t = OutputFormat::Tree)]
50    pub format: OutputFormat,
51
52    #[arg(long, global = true)]
53    pub output: Option<PathBuf>,
54
55    /// Show the full cascade tree and analyzer internals in tree output.
56    #[arg(long, short = 'v', global = true, default_value_t = false)]
57    pub verbose: bool,
58
59    /// Suppress stdout output; exit codes (and --output files) still apply.
60    #[arg(long, short = 'q', global = true, default_value_t = false)]
61    pub quiet: bool,
62
63    /// When to use colors and ANSI styling in tree output.
64    #[arg(long, global = true, value_enum, default_value_t = ColorChoice::Auto)]
65    pub color: ColorChoice,
66
67    /// Include grouped unresolved-import diagnostics in warnings.
68    #[arg(long, global = true, default_value_t = false)]
69    pub explain_unresolved: bool,
70
71    /// Exit non-zero (code 2) when more than this many downstream files are
72    /// impacted (the changed files themselves are not counted).
73    #[arg(long, global = true)]
74    pub fail_threshold: Option<usize>,
75
76    /// Exit non-zero (code 2) when the risk verdict is at or above this tier.
77    #[arg(long, global = true, value_enum)]
78    pub fail_on_risk: Option<RiskTier>,
79}
80
81#[derive(Debug, Clone, Parser)]
82pub enum Command {
83    /// Analyze downstream impact from a named export.
84    Export { file: PathBuf, export_name: String },
85    /// Analyze downstream impact from every export of a file.
86    File { file: PathBuf },
87    /// Blast radius for several files at once (e.g. a pre-commit hook over
88    /// staged files). Pass one or more paths, or `-` to read a
89    /// newline-separated list from stdin (`git diff --name-only | blast-radius files -`).
90    Files {
91        #[arg(required = true, num_args = 1..)]
92        files: Vec<PathBuf>,
93    },
94    /// Print a shell completion script to stdout.
95    Completions {
96        #[arg(value_enum)]
97        shell: Shell,
98    },
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
102pub enum OutputFormat {
103    Tree,
104    Json,
105    Mermaid,
106    Dot,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
110pub enum ColorChoice {
111    /// Color when writing to a terminal (and `NO_COLOR` is unset).
112    Auto,
113    /// Always emit ANSI colors, even when piped or written to a file.
114    Always,
115    /// Never emit ANSI colors.
116    Never,
117}
118
119impl Cli {
120    /// Exit code 2 is reserved for tripped risk gates, so usage errors exit
121    /// with 64 (EX_USAGE) instead of clap's default 2. `--help`/`--version`
122    /// still exit 0.
123    pub fn parse_args() -> Self {
124        match Self::try_parse() {
125            Ok(cli) => cli,
126            Err(error) => {
127                let code = match error.kind() {
128                    ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
129                    _ => 64,
130                };
131                let _ = error.print();
132                std::process::exit(code);
133            }
134        }
135    }
136
137    /// Replace `-` entries in `files` with the newline-separated path list
138    /// from stdin. No-op for other commands or when `-` is absent.
139    pub fn expand_stdin_file_list(&mut self) -> anyhow::Result<()> {
140        let Command::Files { files } = &mut self.command else {
141            return Ok(());
142        };
143        if !files.iter().any(|file| file.as_os_str() == "-") {
144            return Ok(());
145        }
146
147        let mut buffer = String::new();
148        std::io::stdin()
149            .read_to_string(&mut buffer)
150            .map_err(|error| anyhow::anyhow!("failed to read file list from stdin: {error}"))?;
151        let stdin_files: Vec<PathBuf> = buffer
152            .lines()
153            .map(str::trim)
154            .filter(|line| !line.is_empty())
155            .map(PathBuf::from)
156            .collect();
157
158        let mut expanded = Vec::with_capacity(files.len() + stdin_files.len());
159        for file in files.drain(..) {
160            if file.as_os_str() == "-" {
161                expanded.extend(stdin_files.iter().cloned());
162            } else {
163                expanded.push(file);
164            }
165        }
166        if expanded.is_empty() {
167            anyhow::bail!("no files provided: stdin file list was empty");
168        }
169        *files = expanded;
170        Ok(())
171    }
172}