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::config::Config;
use crate::error::SwitcherError;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CurrentMatch {
    /// Target name from config, or empty string if the file did not match any target
    /// and we fell back to the filename stem.
    pub target: String,
    pub basename: String,
    pub parent: PathBuf,
    pub original: PathBuf,
}

pub fn identify_current(path: &Path, config: &Config) -> Result<CurrentMatch, SwitcherError> {
    let file_name = path.file_name().and_then(|s| s.to_str()).ok_or_else(|| {
        SwitcherError::Input(format!("path has no UTF-8 filename: {}", path.display()))
    })?;
    let parent = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();

    let mut best: Option<(usize, String, String)> = None;
    for (target_name, target) in &config.targets {
        for ext in &target.extensions {
            let suffix = format!(".{ext}");
            if !file_name.ends_with(&suffix) {
                continue;
            }
            let excluded = target
                .exclude_suffixes
                .iter()
                .any(|sfx| file_name.ends_with(&format!(".{sfx}")));
            if excluded {
                continue;
            }
            let basename = &file_name[..file_name.len() - suffix.len()];
            let matched_len = ext.len();
            if best.as_ref().map_or(true, |b| matched_len > b.0) {
                best = Some((matched_len, target_name.clone(), basename.to_string()));
            }
        }
    }

    if let Some((_, target, basename)) = best {
        return Ok(CurrentMatch {
            target,
            basename,
            parent,
            original: path.to_path_buf(),
        });
    }

    if config.naming.fallback_to_stem {
        let stem = Path::new(file_name)
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or(file_name)
            .to_string();
        return Ok(CurrentMatch {
            target: String::new(),
            basename: stem,
            parent,
            original: path.to_path_buf(),
        });
    }

    Err(SwitcherError::Input(format!(
        "could not identify an Angular target for {}",
        path.display()
    )))
}

pub fn sibling_for_target(
    base: &CurrentMatch,
    target_name: &str,
    config: &Config,
) -> Option<PathBuf> {
    let target = config.targets.get(target_name)?;
    let ordered_exts: Vec<&String> = if target.preference.is_empty() {
        target.extensions.iter().collect()
    } else {
        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
        let mut out: Vec<&String> = Vec::new();
        for ext in &target.preference {
            if target.extensions.contains(ext) && seen.insert(ext.as_str()) {
                out.push(ext);
            }
        }
        for ext in &target.extensions {
            if seen.insert(ext.as_str()) {
                out.push(ext);
            }
        }
        out
    };

    for ext in ordered_exts {
        let candidate = base.parent.join(format!("{}.{}", base.basename, ext));
        if candidate == base.original {
            continue;
        }
        if candidate.is_file() {
            return Some(candidate);
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    fn cfg() -> Config {
        Config::default()
    }

    #[test]
    fn identifies_ts() {
        let m = identify_current(Path::new("app/foo.component.ts"), &cfg()).unwrap();
        assert_eq!(m.target, "ts");
        assert_eq!(m.basename, "foo.component");
        assert_eq!(m.parent, PathBuf::from("app"));
    }

    #[test]
    fn identifies_spec_not_ts() {
        let m = identify_current(Path::new("app/foo.component.spec.ts"), &cfg()).unwrap();
        assert_eq!(m.target, "spec");
        assert_eq!(m.basename, "foo.component");
    }

    #[test]
    fn identifies_html() {
        let m = identify_current(Path::new("app/foo.component.html"), &cfg()).unwrap();
        assert_eq!(m.target, "html");
        assert_eq!(m.basename, "foo.component");
    }

    #[test]
    fn identifies_scss_as_style() {
        let m = identify_current(Path::new("app/foo.component.scss"), &cfg()).unwrap();
        assert_eq!(m.target, "style");
        assert_eq!(m.basename, "foo.component");
    }

    #[test]
    fn falls_back_to_stem_for_unknown() {
        let m = identify_current(Path::new("README.md"), &cfg()).unwrap();
        assert_eq!(m.target, "");
        assert_eq!(m.basename, "README");
    }
}