use crate::error::SwitcherError;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub cycle: CycleConfig,
#[serde(default = "default_targets")]
pub targets: BTreeMap<String, TargetConfig>,
#[serde(default)]
pub naming: NamingConfig,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CycleConfig {
#[serde(default = "default_cycle_order")]
pub order: Vec<String>,
#[serde(default = "default_true")]
pub wrap: bool,
#[serde(default = "default_true")]
pub skip_missing: bool,
}
impl Default for CycleConfig {
fn default() -> Self {
Self {
order: default_cycle_order(),
wrap: true,
skip_missing: true,
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct TargetConfig {
pub extensions: Vec<String>,
#[serde(default)]
pub exclude_suffixes: Vec<String>,
#[serde(default)]
pub preference: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct NamingConfig {
#[serde(default = "default_true")]
pub fallback_to_stem: bool,
}
impl Default for NamingConfig {
fn default() -> Self {
Self {
fallback_to_stem: true,
}
}
}
fn default_true() -> bool {
true
}
fn default_cycle_order() -> Vec<String> {
vec!["ts".into(), "html".into(), "style".into(), "spec".into()]
}
fn default_targets() -> BTreeMap<String, TargetConfig> {
let mut t = BTreeMap::new();
t.insert(
"ts".into(),
TargetConfig {
extensions: vec!["ts".into()],
exclude_suffixes: vec!["spec.ts".into()],
preference: vec![],
},
);
t.insert(
"html".into(),
TargetConfig {
extensions: vec!["html".into()],
exclude_suffixes: vec![],
preference: vec![],
},
);
t.insert(
"style".into(),
TargetConfig {
extensions: vec!["scss".into(), "css".into(), "sass".into(), "less".into()],
exclude_suffixes: vec![],
preference: vec!["scss".into(), "css".into(), "sass".into(), "less".into()],
},
);
t.insert(
"spec".into(),
TargetConfig {
extensions: vec!["spec.ts".into()],
exclude_suffixes: vec![],
preference: vec![],
},
);
t
}
impl Default for Config {
fn default() -> Self {
Self {
cycle: CycleConfig::default(),
targets: default_targets(),
naming: NamingConfig::default(),
}
}
}
impl Config {
pub fn load(
explicit: Option<&Path>,
project_root: Option<&Path>,
) -> Result<(Self, Option<PathBuf>), SwitcherError> {
if let Some(path) = explicit {
return Self::load_from(path).map(|c| (c, Some(path.to_path_buf())));
}
if let Some(root) = project_root {
let project = root.join(".angular-switcher.toml");
if project.is_file() {
return Self::load_from(&project).map(|c| (c, Some(project)));
}
}
if let Some(dirs) = directories::ProjectDirs::from("", "", "angular-switcher") {
let global = dirs.config_dir().join("config.toml");
if global.is_file() {
return Self::load_from(&global).map(|c| (c, Some(global)));
}
}
Ok((Self::default(), None))
}
fn load_from(path: &Path) -> Result<Self, SwitcherError> {
let text = std::fs::read_to_string(path).map_err(|e| SwitcherError::Io {
path: path.to_path_buf(),
source: e,
})?;
let cfg: Self = toml::from_str(&text)
.map_err(|e| SwitcherError::Config(format!("{}: {e}", path.display())))?;
cfg.validate()?;
Ok(cfg)
}
fn validate(&self) -> Result<(), SwitcherError> {
if self.cycle.order.is_empty() {
return Err(SwitcherError::Config(
"cycle.order must not be empty".into(),
));
}
for name in &self.cycle.order {
if !self.targets.contains_key(name) {
return Err(SwitcherError::Config(format!(
"cycle.order references unknown target '{name}'"
)));
}
}
for (name, target) in &self.targets {
if target.extensions.is_empty() {
return Err(SwitcherError::Config(format!(
"target '{name}' has no extensions"
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_valid() {
Config::default().validate().unwrap();
}
#[test]
fn parses_full_example() {
let src = r#"
[cycle]
order = ["ts", "html"]
wrap = false
skip_missing = false
[targets.ts]
extensions = ["ts"]
exclude_suffixes = ["spec.ts"]
[targets.html]
extensions = ["html"]
[naming]
fallback_to_stem = false
"#;
let cfg: Config = toml::from_str(src).unwrap();
cfg.validate().unwrap();
assert_eq!(cfg.cycle.order, vec!["ts", "html"]);
assert!(!cfg.cycle.wrap);
assert!(!cfg.naming.fallback_to_stem);
}
#[test]
fn cycle_order_must_reference_known_targets() {
let src = r#"
[cycle]
order = ["bogus"]
[targets.ts]
extensions = ["ts"]
"#;
let err = toml::from_str::<Config>(src)
.unwrap()
.validate()
.unwrap_err();
match err {
SwitcherError::Config(msg) => assert!(msg.contains("bogus")),
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn unknown_fields_are_rejected() {
let src = r#"
unrelated = true
[cycle]
order = ["ts"]
[targets.ts]
extensions = ["ts"]
"#;
assert!(toml::from_str::<Config>(src).is_err());
}
}