openapi_snapshot/
config.rs

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}