semver-sync 0.1.2

Keep one version across every crate in a cargo workspace; bumps are checked as docker tags before any manifest is written.
//! Installing the short spelling of the command.

use std::io::ErrorKind;
use std::path::{Path, PathBuf};

use crate::Result;
use crate::cli;

/// Opens the block a re-run replaces, so the rc file never stacks aliases.
const MARKER: &str = "# semver-sync alias";

/// The marker this tool wrote while it was named `semver-rs`. A pre-rename rc
/// file still aliases onto a binary that no longer exists, so a run replaces
/// that block too instead of stacking a second one under it.
const LEGACY_MARKER: &str = "# semver-rs alias";

/// What a run of `semver-sync alias` did to an rc file.
#[derive(Debug, PartialEq, Eq)]
pub enum Outcome {
    Written { path: PathBuf, line: String },
    Already { path: PathBuf },
}

pub fn install(args: cli::Alias) -> Result<Outcome> {
    // Only ask the shell where to write when the caller did not say.
    let path = match args.file {
        Some(path) => path,
        None => rc_file()?,
    };
    let line = format!("alias {}=semver-sync", args.name);

    let current = match std::fs::read_to_string(&path) {
        Ok(text) => text,
        Err(err) if err.kind() == ErrorKind::NotFound => String::new(),
        Err(err) => return Err(format!("cannot read {}: {err}", path.display())),
    };

    let updated = replace_block(&current, MARKER, &line)
        .or_else(|| replace_block(&current, LEGACY_MARKER, &line))
        .unwrap_or_else(|| append_block(&current, &line));

    if updated == current {
        return Ok(Outcome::Already { path });
    }

    std::fs::write(&path, &updated)
        .map_err(|err| format!("cannot write {}: {err}", path.display()))?;

    Ok(Outcome::Written { path, line })
}

/// The rc file for the login shell, decided by its name.
fn rc_file() -> Result<PathBuf> {
    let shell = std::env::var("SHELL").unwrap_or_default();
    let name = Path::new(&shell)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("this shell");
    let home = std::env::var("HOME")
        .map_err(|_| String::from("cannot find your home directory; pass --file"))?;

    match name {
        "zsh" => Ok(Path::new(&home).join(".zshrc")),
        "bash" => Ok(Path::new(&home).join(".bashrc")),
        other => Err(format!("no known rc file for {other}; pass --file")),
    }
}

/// Swap the marker block's contents, leaving the rest of the file untouched.
fn replace_block(current: &str, marker: &str, line: &str) -> Option<String> {
    let at = current.find(marker)?;
    let after_marker = &current[at..];
    let marker_end = after_marker
        .find('\n')
        .map_or(current.len(), |offset| at + offset + 1);

    // The alias line is the one below the marker, when it is there at all.
    let tail = &current[marker_end..];
    let alias_end = if tail.starts_with("alias ") {
        tail.find('\n')
            .map_or(current.len(), |offset| marker_end + offset + 1)
    } else {
        marker_end
    };

    let mut updated = String::with_capacity(current.len());
    updated.push_str(&current[..at]);
    updated.push_str(&block(line));
    updated.push_str(&current[alias_end..]);

    Some(updated)
}

fn append_block(current: &str, line: &str) -> String {
    let mut updated = current.to_string();

    if !updated.is_empty() && !updated.ends_with('\n') {
        updated.push('\n');
    }
    if !updated.is_empty() {
        updated.push('\n');
    }

    updated.push_str(&block(line));

    updated
}

fn block(line: &str) -> String {
    format!("{MARKER}\n{line}\n")
}

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

    fn install_to(path: &Path, name: &str) -> Outcome {
        install(cli::Alias {
            name: name.to_string(),
            file: Some(path.to_path_buf()),
        })
        .expect("install")
    }

    #[test]
    fn writes_the_alias_into_a_file_that_has_no_block_yet() {
        let dir = tempfile::tempdir().expect("temp dir");
        let path = dir.path().join(".zshrc");
        std::fs::write(&path, "alias ls=eza\n").expect("write rc");

        let outcome = install_to(&path, "ss");

        assert!(matches!(outcome, Outcome::Written { .. }));
        assert_eq!(
            std::fs::read_to_string(&path).expect("read rc"),
            "alias ls=eza\n\n# semver-sync alias\nalias ss=semver-sync\n"
        );
    }

    #[test]
    fn running_it_twice_changes_nothing() {
        let dir = tempfile::tempdir().expect("temp dir");
        let path = dir.path().join(".zshrc");
        std::fs::write(&path, "alias ls=eza\n").expect("write rc");

        install_to(&path, "ss");
        let second = install_to(&path, "ss");

        assert_eq!(second, Outcome::Already { path: path.clone() });
        assert_eq!(
            std::fs::read_to_string(&path).expect("read rc"),
            "alias ls=eza\n\n# semver-sync alias\nalias ss=semver-sync\n"
        );
    }

    #[test]
    fn another_name_replaces_the_block() {
        let dir = tempfile::tempdir().expect("temp dir");
        let path = dir.path().join(".zshrc");
        std::fs::write(&path, "# semver-sync alias\nalias ss=semver-sync\n").expect("write rc");

        install_to(&path, "ver");

        assert_eq!(
            std::fs::read_to_string(&path).expect("read rc"),
            "# semver-sync alias\nalias ver=semver-sync\n"
        );
    }

    #[test]
    fn a_stripped_block_is_filled_again_in_place() {
        let dir = tempfile::tempdir().expect("temp dir");
        let path = dir.path().join(".zshrc");
        std::fs::write(&path, "top = 1\n# semver-sync alias\nbottom = 2\n").expect("write rc");

        install_to(&path, "ss");

        assert_eq!(
            std::fs::read_to_string(&path).expect("read rc"),
            "top = 1\n# semver-sync alias\nalias ss=semver-sync\nbottom = 2\n"
        );
    }

    #[test]
    fn a_missing_file_is_created() {
        let dir = tempfile::tempdir().expect("temp dir");
        let path = dir.path().join(".bashrc");

        install_to(&path, "ss");

        assert_eq!(
            std::fs::read_to_string(&path).expect("read rc"),
            "# semver-sync alias\nalias ss=semver-sync\n"
        );
    }

    #[test]
    fn a_pre_rename_block_is_replaced_in_place() {
        let dir = tempfile::tempdir().expect("temp dir");
        let path = dir.path().join(".zshrc");
        std::fs::write(
            &path,
            "alias ls=eza\n\n# semver-rs alias\nalias sv=semver-rs\nexport PATH=$PATH\n",
        )
        .expect("write rc");

        install_to(&path, "ss");

        // The whole old block goes, `sv` with it: it pointed at a binary that
        // no longer exists, and `--name sv` is how that spelling comes back.
        assert_eq!(
            std::fs::read_to_string(&path).expect("read rc"),
            "alias ls=eza\n\n# semver-sync alias\nalias ss=semver-sync\nexport PATH=$PATH\n"
        );
    }
}