1use std::path::{Path, PathBuf};
2
3use crate::cli::{Cli, Command, DEFAULT_OUT, DEFAULT_REDUCE, DEFAULT_URL, OutputProfile};
4use crate::errors::AppError;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ReduceKey {
8 Paths,
9 Components,
10}
11
12impl ReduceKey {
13 pub fn as_str(self) -> &'static str {
14 match self {
15 ReduceKey::Paths => "paths",
16 ReduceKey::Components => "components",
17 }
18 }
19}
20
21#[derive(Debug, Clone, Copy)]
22pub enum Mode {
23 Snapshot,
24 Watch { interval_ms: u64 },
25}
26
27#[derive(Debug)]
28pub struct Config {
29 pub url: String,
30 pub url_from_default: bool,
31 pub out: Option<PathBuf>,
32 pub outline_out: Option<PathBuf>,
33 pub reduce: Vec<ReduceKey>,
34 pub profile: OutputProfile,
35 pub minify: bool,
36 pub timeout_ms: u64,
37 pub headers: Vec<String>,
38 pub stdout: bool,
39}
40
41impl Config {
42 pub fn from_cli(cli: Cli) -> Result<(Self, Mode), AppError> {
43 let (mode, no_outline) = match cli.command {
44 Some(Command::Watch(args)) => (
45 Mode::Watch {
46 interval_ms: args.interval_ms,
47 },
48 args.no_outline,
49 ),
50 None => (Mode::Snapshot, false),
51 };
52
53 let reduce_value = match (&cli.common.reduce, mode, cli.common.profile) {
54 (Some(value), _, _) => Some(value.as_str()),
55 (None, Mode::Watch { .. }, OutputProfile::Full) => Some(DEFAULT_REDUCE),
56 _ => None,
57 };
58 let reduce = match reduce_value {
59 Some(value) => parse_reduce_list(value)?,
60 None => Vec::new(),
61 };
62
63 let url_from_default = cli.common.url.is_none();
64 let url = cli.common.url.unwrap_or_else(|| DEFAULT_URL.to_string());
65 let out = if cli.common.stdout {
66 cli.common.out.clone()
67 } else {
68 Some(cli.common.out.clone().unwrap_or_else(|| PathBuf::from(DEFAULT_OUT)))
69 };
70 let outline_out = if cli.common.stdout {
71 None
72 } else {
73 match cli.common.outline_out {
74 Some(path) => Some(path),
75 None => match (cli.common.profile, no_outline) {
76 (OutputProfile::Full, false) => {
77 Some(derive_outline_path(out.as_ref().unwrap()))
78 }
79 _ => None,
80 },
81 }
82 };
83
84 Ok((
85 Self {
86 url,
87 url_from_default,
88 out,
89 outline_out,
90 reduce,
91 profile: cli.common.profile,
92 minify: cli.common.minify,
93 timeout_ms: cli.common.timeout_ms,
94 headers: cli.common.header,
95 stdout: cli.common.stdout,
96 },
97 mode,
98 ))
99 }
100}
101
102pub fn validate_config(config: &Config) -> Result<(), AppError> {
103 if !config.stdout && config.out.is_none() {
104 return Err(AppError::Usage(
105 "--out is required unless --stdout is set.".to_string(),
106 ));
107 }
108 if config.profile == OutputProfile::Outline && !config.reduce.is_empty() {
109 return Err(AppError::Usage(
110 "--reduce is not supported with --profile outline.".to_string(),
111 ));
112 }
113 if config.profile == OutputProfile::Outline && config.outline_out.is_some() {
114 return Err(AppError::Usage(
115 "--outline-out is not supported with --profile outline.".to_string(),
116 ));
117 }
118 Ok(())
119}
120
121pub fn parse_reduce_list(value: &str) -> Result<Vec<ReduceKey>, AppError> {
122 if value.is_empty() {
123 return Err(AppError::Reduce("reduce list cannot be empty".to_string()));
124 }
125 let mut out = Vec::new();
126 for raw in value.split(',') {
127 let trimmed = raw.trim();
128 if trimmed.is_empty() {
129 continue;
130 }
131 if trimmed.to_lowercase() != trimmed {
132 return Err(AppError::Reduce(format!(
133 "reduce values must be lowercase: {trimmed}"
134 )));
135 }
136 match trimmed {
137 "paths" => push_unique(&mut out, ReduceKey::Paths),
138 "components" => push_unique(&mut out, ReduceKey::Components),
139 _ => {
140 return Err(AppError::Reduce(format!(
141 "unsupported reduce value: {trimmed}"
142 )));
143 }
144 }
145 }
146 if out.is_empty() {
147 return Err(AppError::Reduce("reduce list cannot be empty".to_string()));
148 }
149 Ok(out)
150}
151
152fn push_unique(items: &mut Vec<ReduceKey>, key: ReduceKey) {
153 if !items.contains(&key) {
154 items.push(key);
155 }
156}
157
158fn derive_outline_path(out_path: &Path) -> PathBuf {
160 let stem = out_path.file_stem().unwrap_or_default().to_string_lossy();
161 let new_name = format!("{}.outline.json", stem);
162 out_path.with_file_name(new_name)
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::cli::{CommonArgs, DEFAULT_OUTLINE_OUT, WatchArgs};
169
170 #[test]
171 fn parse_reduce_list_accepts_paths_components() {
172 let keys = parse_reduce_list("paths,components").unwrap();
173 assert_eq!(keys, vec![ReduceKey::Paths, ReduceKey::Components]);
174 }
175
176 #[test]
177 fn parse_reduce_list_rejects_mixed_case() {
178 let err = parse_reduce_list("Paths").unwrap_err();
179 assert!(matches!(err, AppError::Reduce(_)));
180 }
181
182 #[test]
183 fn defaults_apply_for_watch_mode() {
184 let cli = Cli {
185 command: Some(Command::Watch(WatchArgs {
186 interval_ms: 500,
187 no_outline: false,
188 })),
189 common: CommonArgs {
190 url: None,
191 out: None,
192 outline_out: None,
193 reduce: None,
194 profile: OutputProfile::Full,
195 minify: true,
196 timeout_ms: 10_000,
197 header: Vec::new(),
198 stdout: false,
199 },
200 };
201 let (config, mode) = Config::from_cli(cli).unwrap();
202 assert_eq!(config.url, DEFAULT_URL);
203 assert!(config.url_from_default);
204 assert_eq!(config.out.unwrap(), PathBuf::from(DEFAULT_OUT));
205 assert_eq!(
206 config.outline_out.unwrap(),
207 PathBuf::from(DEFAULT_OUTLINE_OUT)
208 );
209 assert_eq!(config.reduce, vec![ReduceKey::Paths, ReduceKey::Components]);
210 assert!(matches!(mode, Mode::Watch { .. }));
211 }
212
213 #[test]
214 fn watch_mode_respects_no_outline() {
215 let cli = Cli {
216 command: Some(Command::Watch(WatchArgs {
217 interval_ms: 500,
218 no_outline: true,
219 })),
220 common: CommonArgs {
221 url: None,
222 out: None,
223 outline_out: None,
224 reduce: None,
225 profile: OutputProfile::Full,
226 minify: true,
227 timeout_ms: 10_000,
228 header: Vec::new(),
229 stdout: false,
230 },
231 };
232 let (config, _) = Config::from_cli(cli).unwrap();
233 assert!(config.outline_out.is_none());
234 }
235}