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    /// Dump the whole-repo import graph (every file and resolved import edge).
95    /// Useful for visualization or feeding other tools; `--format json` is the
96    /// natural choice, with `mermaid`/`dot` for diagrams.
97    Graph,
98    /// Print a shell completion script to stdout.
99    Completions {
100        #[arg(value_enum)]
101        shell: Shell,
102    },
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
106pub enum OutputFormat {
107    Tree,
108    Json,
109    Mermaid,
110    Dot,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
114pub enum ColorChoice {
115    /// Color when writing to a terminal (and `NO_COLOR` is unset).
116    Auto,
117    /// Always emit ANSI colors, even when piped or written to a file.
118    Always,
119    /// Never emit ANSI colors.
120    Never,
121}
122
123impl Cli {
124    /// Exit code 2 is reserved for tripped risk gates, so usage errors exit
125    /// with 64 (EX_USAGE) instead of clap's default 2. `--help`/`--version`
126    /// still exit 0.
127    pub fn parse_args() -> Self {
128        match Self::try_parse() {
129            Ok(cli) => cli,
130            Err(error) => {
131                let code = match error.kind() {
132                    ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => 0,
133                    _ => 64,
134                };
135                let _ = error.print();
136                std::process::exit(code);
137            }
138        }
139    }
140
141    /// Replace `-` entries in `files` with the newline-separated path list
142    /// from stdin. No-op for other commands or when `-` is absent.
143    pub fn expand_stdin_file_list(&mut self) -> anyhow::Result<()> {
144        let Command::Files { files } = &mut self.command else {
145            return Ok(());
146        };
147        if !files.iter().any(|file| file.as_os_str() == "-") {
148            return Ok(());
149        }
150
151        let mut buffer = String::new();
152        std::io::stdin()
153            .read_to_string(&mut buffer)
154            .map_err(|error| anyhow::anyhow!("failed to read file list from stdin: {error}"))?;
155        let stdin_files: Vec<PathBuf> = buffer
156            .lines()
157            .map(str::trim)
158            .filter(|line| !line.is_empty())
159            .map(PathBuf::from)
160            .collect();
161
162        let mut expanded = Vec::with_capacity(files.len() + stdin_files.len());
163        for file in files.drain(..) {
164            if file.as_os_str() == "-" {
165                expanded.extend(stdin_files.iter().cloned());
166            } else {
167                expanded.push(file);
168            }
169        }
170        if expanded.is_empty() {
171            anyhow::bail!("no files provided: stdin file list was empty");
172        }
173        *files = expanded;
174        Ok(())
175    }
176}