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 crate::resolver::{sibling_for_target, CurrentMatch};
use std::path::PathBuf;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
    Direct,
    CycleForward,
    CycleBackward,
}

pub fn select(
    current: &CurrentMatch,
    mode: Mode,
    target: Option<&str>,
    config: &Config,
) -> Result<PathBuf, SwitcherError> {
    match mode {
        Mode::Direct => {
            let target =
                target.ok_or_else(|| SwitcherError::Input("--to requires a target name".into()))?;
            if !config.targets.contains_key(target) {
                return Err(SwitcherError::Input(format!(
                    "unknown target '{target}'. Known targets: {}",
                    config
                        .targets
                        .keys()
                        .cloned()
                        .collect::<Vec<_>>()
                        .join(", ")
                )));
            }
            sibling_for_target(current, target, config).ok_or_else(|| SwitcherError::NoSibling {
                target: Some(target.to_string()),
            })
        }
        Mode::CycleForward | Mode::CycleBackward => cycle(current, mode, config),
    }
}

fn cycle(current: &CurrentMatch, mode: Mode, config: &Config) -> Result<PathBuf, SwitcherError> {
    let order = &config.cycle.order;
    let n: i64 = i64::try_from(order.len())
        .map_err(|_| SwitcherError::Config("cycle.order is too large".into()))?;
    let dir: i64 = if mode == Mode::CycleForward { 1 } else { -1 };
    let start_idx = order.iter().position(|t| t == &current.target);

    for step in 1..=n {
        let raw: i64 = match start_idx {
            Some(idx) => {
                let idx_i = i64::try_from(idx).expect("position within Vec fits in i64");
                idx_i + dir * step
            }
            None => {
                if mode == Mode::CycleForward {
                    step - 1
                } else {
                    n - step
                }
            }
        };
        let idx_i64: i64 = if config.cycle.wrap {
            raw.rem_euclid(n)
        } else if !(0..n).contains(&raw) {
            continue;
        } else {
            raw
        };
        let idx = usize::try_from(idx_i64).expect("non-negative after rem_euclid / bounds check");
        let candidate_target = &order[idx];
        if !current.target.is_empty() && candidate_target == &current.target {
            continue;
        }
        if let Some(path) = sibling_for_target(current, candidate_target, config) {
            return Ok(path);
        }
        if !config.cycle.skip_missing {
            return Err(SwitcherError::NoSibling {
                target: Some(candidate_target.clone()),
            });
        }
    }
    Err(SwitcherError::NoSibling { target: None })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::resolver::identify_current;
    use std::fs;
    use std::path::Path;
    use tempfile::tempdir;

    fn touch(dir: &Path, name: &str) -> PathBuf {
        let p = dir.join(name);
        fs::write(&p, "").unwrap();
        p
    }

    #[test]
    fn cycle_ts_to_html_when_all_exist() {
        let dir = tempdir().unwrap();
        let ts = touch(dir.path(), "foo.component.ts");
        touch(dir.path(), "foo.component.html");
        touch(dir.path(), "foo.component.scss");
        touch(dir.path(), "foo.component.spec.ts");

        let cfg = Config::default();
        let cur = identify_current(&ts, &cfg).unwrap();
        let next = select(&cur, Mode::CycleForward, None, &cfg).unwrap();
        assert_eq!(next.file_name().unwrap(), "foo.component.html");
    }

    #[test]
    fn cycle_full_loop() {
        let dir = tempdir().unwrap();
        let ts = touch(dir.path(), "foo.component.ts");
        touch(dir.path(), "foo.component.html");
        touch(dir.path(), "foo.component.scss");
        touch(dir.path(), "foo.component.spec.ts");
        let cfg = Config::default();

        let cur = identify_current(&ts, &cfg).unwrap();
        let html = select(&cur, Mode::CycleForward, None, &cfg).unwrap();
        let cur2 = identify_current(&html, &cfg).unwrap();
        let scss = select(&cur2, Mode::CycleForward, None, &cfg).unwrap();
        let cur3 = identify_current(&scss, &cfg).unwrap();
        let spec = select(&cur3, Mode::CycleForward, None, &cfg).unwrap();
        let cur4 = identify_current(&spec, &cfg).unwrap();
        let back_to_ts = select(&cur4, Mode::CycleForward, None, &cfg).unwrap();

        assert_eq!(html.file_name().unwrap(), "foo.component.html");
        assert_eq!(scss.file_name().unwrap(), "foo.component.scss");
        assert_eq!(spec.file_name().unwrap(), "foo.component.spec.ts");
        assert_eq!(back_to_ts.file_name().unwrap(), "foo.component.ts");
    }

    #[test]
    fn cycle_skips_missing_sibling() {
        let dir = tempdir().unwrap();
        let ts = touch(dir.path(), "foo.component.ts");
        // No html, no scss
        touch(dir.path(), "foo.component.spec.ts");

        let cfg = Config::default();
        let cur = identify_current(&ts, &cfg).unwrap();
        let next = select(&cur, Mode::CycleForward, None, &cfg).unwrap();
        assert_eq!(next.file_name().unwrap(), "foo.component.spec.ts");
    }

    #[test]
    fn cycle_reverse_goes_backwards() {
        let dir = tempdir().unwrap();
        let ts = touch(dir.path(), "foo.component.ts");
        touch(dir.path(), "foo.component.html");
        touch(dir.path(), "foo.component.scss");
        touch(dir.path(), "foo.component.spec.ts");

        let cfg = Config::default();
        let cur = identify_current(&ts, &cfg).unwrap();
        let prev = select(&cur, Mode::CycleBackward, None, &cfg).unwrap();
        assert_eq!(prev.file_name().unwrap(), "foo.component.spec.ts");
    }

    #[test]
    fn direct_to_spec() {
        let dir = tempdir().unwrap();
        let ts = touch(dir.path(), "foo.component.ts");
        touch(dir.path(), "foo.component.spec.ts");
        let cfg = Config::default();
        let cur = identify_current(&ts, &cfg).unwrap();
        let spec = select(&cur, Mode::Direct, Some("spec"), &cfg).unwrap();
        assert_eq!(spec.file_name().unwrap(), "foo.component.spec.ts");
    }

    #[test]
    fn direct_unknown_target_errors() {
        let dir = tempdir().unwrap();
        let ts = touch(dir.path(), "foo.component.ts");
        let cfg = Config::default();
        let cur = identify_current(&ts, &cfg).unwrap();
        let err = select(&cur, Mode::Direct, Some("nope"), &cfg).unwrap_err();
        assert!(matches!(err, SwitcherError::Input(_)));
    }

    #[test]
    fn direct_missing_sibling_errors() {
        let dir = tempdir().unwrap();
        let ts = touch(dir.path(), "foo.component.ts");
        let cfg = Config::default();
        let cur = identify_current(&ts, &cfg).unwrap();
        let err = select(&cur, Mode::Direct, Some("spec"), &cfg).unwrap_err();
        match err {
            SwitcherError::NoSibling { target } => assert_eq!(target.as_deref(), Some("spec")),
            other => panic!("unexpected: {other:?}"),
        }
    }

    #[test]
    fn style_preference_prefers_scss() {
        let dir = tempdir().unwrap();
        let ts = touch(dir.path(), "foo.component.ts");
        touch(dir.path(), "foo.component.css");
        touch(dir.path(), "foo.component.scss");
        let cfg = Config::default();
        let cur = identify_current(&ts, &cfg).unwrap();
        let style = select(&cur, Mode::Direct, Some("style"), &cfg).unwrap();
        assert_eq!(style.file_name().unwrap(), "foo.component.scss");
    }

    #[test]
    fn no_siblings_returns_no_sibling_err() {
        let dir = tempdir().unwrap();
        let ts = touch(dir.path(), "lonely.component.ts");
        let cfg = Config::default();
        let cur = identify_current(&ts, &cfg).unwrap();
        let err = select(&cur, Mode::CycleForward, None, &cfg).unwrap_err();
        assert!(matches!(err, SwitcherError::NoSibling { .. }));
    }
}