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}