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
11fn 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 #[arg(long, short = 'v', global = true, default_value_t = false)]
57 pub verbose: bool,
58
59 #[arg(long, short = 'q', global = true, default_value_t = false)]
61 pub quiet: bool,
62
63 #[arg(long, global = true, value_enum, default_value_t = ColorChoice::Auto)]
65 pub color: ColorChoice,
66
67 #[arg(long, global = true, default_value_t = false)]
69 pub explain_unresolved: bool,
70
71 #[arg(long, global = true)]
74 pub fail_threshold: Option<usize>,
75
76 #[arg(long, global = true, value_enum)]
78 pub fail_on_risk: Option<RiskTier>,
79}
80
81#[derive(Debug, Clone, Parser)]
82pub enum Command {
83 Export { file: PathBuf, export_name: String },
85 File { file: PathBuf },
87 Files {
91 #[arg(required = true, num_args = 1..)]
92 files: Vec<PathBuf>,
93 },
94 Graph,
98 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 Auto,
117 Always,
119 Never,
121}
122
123impl Cli {
124 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 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}