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