angular-switcher 0.1.0

Switch between Angular component files (.ts, .html, styles, .spec.ts) from the Zed editor with a customizable keybinding.
Documentation
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());
    }
}