Skip to main content

angular_switcher/
config.rs

1use crate::error::SwitcherError;
2use serde::Deserialize;
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
7#[serde(deny_unknown_fields)]
8pub struct Config {
9    #[serde(default)]
10    pub cycle: CycleConfig,
11
12    #[serde(default = "default_targets")]
13    pub targets: BTreeMap<String, TargetConfig>,
14
15    #[serde(default)]
16    pub naming: NamingConfig,
17}
18
19#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
20#[serde(deny_unknown_fields)]
21pub struct CycleConfig {
22    #[serde(default = "default_cycle_order")]
23    pub order: Vec<String>,
24
25    #[serde(default = "default_true")]
26    pub wrap: bool,
27
28    #[serde(default = "default_true")]
29    pub skip_missing: bool,
30}
31
32impl Default for CycleConfig {
33    fn default() -> Self {
34        Self {
35            order: default_cycle_order(),
36            wrap: true,
37            skip_missing: true,
38        }
39    }
40}
41
42#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
43#[serde(deny_unknown_fields)]
44pub struct TargetConfig {
45    pub extensions: Vec<String>,
46
47    #[serde(default)]
48    pub exclude_suffixes: Vec<String>,
49
50    #[serde(default)]
51    pub preference: Vec<String>,
52}
53
54#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
55#[serde(deny_unknown_fields)]
56pub struct NamingConfig {
57    #[serde(default = "default_true")]
58    pub fallback_to_stem: bool,
59}
60
61impl Default for NamingConfig {
62    fn default() -> Self {
63        Self {
64            fallback_to_stem: true,
65        }
66    }
67}
68
69fn default_true() -> bool {
70    true
71}
72
73fn default_cycle_order() -> Vec<String> {
74    vec!["ts".into(), "html".into(), "style".into(), "spec".into()]
75}
76
77fn default_targets() -> BTreeMap<String, TargetConfig> {
78    let mut t = BTreeMap::new();
79    t.insert(
80        "ts".into(),
81        TargetConfig {
82            extensions: vec!["ts".into()],
83            exclude_suffixes: vec!["spec.ts".into()],
84            preference: vec![],
85        },
86    );
87    t.insert(
88        "html".into(),
89        TargetConfig {
90            extensions: vec!["html".into()],
91            exclude_suffixes: vec![],
92            preference: vec![],
93        },
94    );
95    t.insert(
96        "style".into(),
97        TargetConfig {
98            extensions: vec!["scss".into(), "css".into(), "sass".into(), "less".into()],
99            exclude_suffixes: vec![],
100            preference: vec!["scss".into(), "css".into(), "sass".into(), "less".into()],
101        },
102    );
103    t.insert(
104        "spec".into(),
105        TargetConfig {
106            extensions: vec!["spec.ts".into()],
107            exclude_suffixes: vec![],
108            preference: vec![],
109        },
110    );
111    t
112}
113
114impl Default for Config {
115    fn default() -> Self {
116        Self {
117            cycle: CycleConfig::default(),
118            targets: default_targets(),
119            naming: NamingConfig::default(),
120        }
121    }
122}
123
124impl Config {
125    pub fn load(
126        explicit: Option<&Path>,
127        project_root: Option<&Path>,
128    ) -> Result<(Self, Option<PathBuf>), SwitcherError> {
129        if let Some(path) = explicit {
130            return Self::load_from(path).map(|c| (c, Some(path.to_path_buf())));
131        }
132        if let Some(root) = project_root {
133            let project = root.join(".angular-switcher.toml");
134            if project.is_file() {
135                return Self::load_from(&project).map(|c| (c, Some(project)));
136            }
137        }
138        if let Some(dirs) = directories::ProjectDirs::from("", "", "angular-switcher") {
139            let global = dirs.config_dir().join("config.toml");
140            if global.is_file() {
141                return Self::load_from(&global).map(|c| (c, Some(global)));
142            }
143        }
144        Ok((Self::default(), None))
145    }
146
147    fn load_from(path: &Path) -> Result<Self, SwitcherError> {
148        let text = std::fs::read_to_string(path).map_err(|e| SwitcherError::Io {
149            path: path.to_path_buf(),
150            source: e,
151        })?;
152        let cfg: Self = toml::from_str(&text)
153            .map_err(|e| SwitcherError::Config(format!("{}: {e}", path.display())))?;
154        cfg.validate()?;
155        Ok(cfg)
156    }
157
158    fn validate(&self) -> Result<(), SwitcherError> {
159        if self.cycle.order.is_empty() {
160            return Err(SwitcherError::Config(
161                "cycle.order must not be empty".into(),
162            ));
163        }
164        for name in &self.cycle.order {
165            if !self.targets.contains_key(name) {
166                return Err(SwitcherError::Config(format!(
167                    "cycle.order references unknown target '{name}'"
168                )));
169            }
170        }
171        for (name, target) in &self.targets {
172            if target.extensions.is_empty() {
173                return Err(SwitcherError::Config(format!(
174                    "target '{name}' has no extensions"
175                )));
176            }
177        }
178        Ok(())
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn default_config_is_valid() {
188        Config::default().validate().unwrap();
189    }
190
191    #[test]
192    fn parses_full_example() {
193        let src = r#"
194            [cycle]
195            order = ["ts", "html"]
196            wrap = false
197            skip_missing = false
198
199            [targets.ts]
200            extensions = ["ts"]
201            exclude_suffixes = ["spec.ts"]
202
203            [targets.html]
204            extensions = ["html"]
205
206            [naming]
207            fallback_to_stem = false
208        "#;
209        let cfg: Config = toml::from_str(src).unwrap();
210        cfg.validate().unwrap();
211        assert_eq!(cfg.cycle.order, vec!["ts", "html"]);
212        assert!(!cfg.cycle.wrap);
213        assert!(!cfg.naming.fallback_to_stem);
214    }
215
216    #[test]
217    fn cycle_order_must_reference_known_targets() {
218        let src = r#"
219            [cycle]
220            order = ["bogus"]
221
222            [targets.ts]
223            extensions = ["ts"]
224        "#;
225        let err = toml::from_str::<Config>(src)
226            .unwrap()
227            .validate()
228            .unwrap_err();
229        match err {
230            SwitcherError::Config(msg) => assert!(msg.contains("bogus")),
231            other => panic!("unexpected error: {other:?}"),
232        }
233    }
234
235    #[test]
236    fn unknown_fields_are_rejected() {
237        let src = r#"
238            unrelated = true
239
240            [cycle]
241            order = ["ts"]
242
243            [targets.ts]
244            extensions = ["ts"]
245        "#;
246        assert!(toml::from_str::<Config>(src).is_err());
247    }
248}