openapi_snapshot/
config.rs

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
158/// Derive outline path from output path: `foo.json` -> `foo.outline.json`
159fn 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}