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 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 Auto,
113 Always,
115 Never,
117}
118
119impl Cli {
120 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 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}