angular-switcher 0.1.0

Switch between Angular component files (.ts, .html, styles, .spec.ts) from the Zed editor with a customizable keybinding.
Documentation
#![forbid(unsafe_code)]

use angular_switcher::cli::Cli;
use angular_switcher::{
    config::Config,
    error::SwitcherError,
    opener::open_in_zed,
    resolver::identify_current,
    strategy::{select, Mode},
};
use clap::Parser;
use std::path::{Path, PathBuf};
use std::process::ExitCode;

fn main() -> ExitCode {
    let cli = Cli::parse();
    match run(&cli) {
        Ok(()) => ExitCode::SUCCESS,
        Err(err) => {
            eprintln!("angular-switcher: {err}");
            ExitCode::from(u8::try_from(err.exit_code()).unwrap_or(1))
        }
    }
}

fn run(cli: &Cli) -> Result<(), SwitcherError> {
    let file = resolve_input_file(cli)?;

    if file.as_os_str().is_empty() {
        return Err(SwitcherError::Input(
            "no file path given and $ZED_FILE is unset".into(),
        ));
    }

    if has_nul(&file) {
        return Err(SwitcherError::Input("file path contains a NUL byte".into()));
    }

    let project_root = std::env::var_os("ZED_WORKTREE_ROOT").map(PathBuf::from);

    let (config, source) = Config::load(cli.config.as_deref(), project_root.as_deref())?;
    if cli.verbose {
        match source {
            Some(p) => eprintln!("config: {}", p.display()),
            None => eprintln!("config: built-in defaults"),
        }
    }

    let current = identify_current(&file, &config)?;
    if cli.verbose {
        eprintln!(
            "current: target='{}' basename='{}' parent='{}'",
            current.target,
            current.basename,
            current.parent.display()
        );
    }

    let mode = match (&cli.to, cli.cycle, cli.reverse) {
        (Some(_), _, _) => Mode::Direct,
        (None, _, true) => Mode::CycleBackward,
        _ => Mode::CycleForward,
    };

    let target_ref = cli.to.as_deref();
    let next = select(&current, mode, target_ref, &config)?;

    if cli.print {
        println!("{}", next.display());
        return Ok(());
    }
    if cli.verbose {
        eprintln!("opening: {}", next.display());
    }
    if cli.no_launch {
        return Ok(());
    }
    open_in_zed(&next)
}

fn resolve_input_file(cli: &Cli) -> Result<PathBuf, SwitcherError> {
    if let Some(p) = &cli.file {
        return Ok(p.clone());
    }
    if let Some(env) = std::env::var_os("ZED_FILE") {
        return Ok(PathBuf::from(env));
    }
    Err(SwitcherError::Input(
        "missing input file. Pass a path or set $ZED_FILE (Zed tasks do this automatically)."
            .into(),
    ))
}

fn has_nul(path: &Path) -> bool {
    path.as_os_str().to_string_lossy().as_bytes().contains(&0)
}