shellcomp 0.1.12

Shell completion installation and activation helpers for Rust CLI tools
Documentation
use std::path::{Path, PathBuf};

use crate::error::{Error, Result};
use crate::infra::{
    env::Environment,
    fs,
    managed_block::{self, ManagedBlock},
};
use crate::model::{
    ActivationMode, ActivationReport, Availability, CleanupReport, LegacyManagedBlock,
};
use crate::shell::{ActivationOutcome, CleanupOutcome, MigrationOutcome, migrate_profile_blocks};

pub(crate) fn install(
    env: &Environment,
    program_name: &str,
    target_path: &Path,
) -> Result<ActivationOutcome> {
    let rc_path = zshrc_path(env)?;
    let block = managed_block(program_name, target_path)?;
    managed_block::upsert(&rc_path, &block)?;

    Ok(ActivationOutcome {
        report: ActivationReport {
            mode: ActivationMode::ManagedRcBlock,
            availability: Availability::AvailableAfterSource,
            location: Some(rc_path.clone()),
            reason: Some(
                "shellcomp added a managed block to update `fpath` and run `compinit -i` when needed."
                    .to_owned(),
            ),
            next_step: Some(format!(
                "Run `source {}` or start a new Zsh session.",
                shell_quote(&rc_path)?
            )),
        },
        affected_locations: vec![rc_path],
    })
}

pub(crate) fn uninstall(
    env: &Environment,
    program_name: &str,
    target_path: &Path,
) -> Result<CleanupOutcome> {
    let rc_path = zshrc_path(env)?;
    let block = managed_block(program_name, target_path)?;
    let rc_change = managed_block::remove(&rc_path, &block)?;

    Ok(CleanupOutcome {
        cleanup: CleanupReport {
            mode: ActivationMode::ManagedRcBlock,
            change: rc_change,
            location: Some(rc_path.clone()),
            reason: Some(match rc_change {
                crate::FileChange::Removed => {
                    "Removed the managed Zsh activation block from .zshrc.".to_owned()
                }
                crate::FileChange::Absent => {
                    "No managed Zsh activation block was present in .zshrc.".to_owned()
                }
                _ => "Zsh activation cleanup completed.".to_owned(),
            }),
            next_step: None,
        },
        affected_locations: vec![rc_path],
    })
}

pub(crate) fn detect(
    env: &Environment,
    program_name: &str,
    target_path: &Path,
) -> Result<ActivationReport> {
    let rc_path = zshrc_path(env)?;
    let block = managed_block(program_name, target_path)?;
    let wired = managed_block::matches(&rc_path, &block)?;
    let quoted_rc_path = shell_quote(&rc_path)?;
    let quoted_completion_dir = shell_quote(target_path.parent().unwrap_or(target_path))?;

    if !fs::file_exists(target_path) {
        return Ok(ActivationReport {
            mode: ActivationMode::ManagedRcBlock,
            availability: Availability::ManualActionRequired,
            location: Some(if wired {
                rc_path.clone()
            } else {
                target_path.to_path_buf()
            }),
            reason: Some(if wired {
                format!(
                    "Managed zsh activation block is present, but completion file `{}` is not installed.",
                    target_path.display()
                )
            } else {
                format!(
                    "Completion file `{}` is not installed.",
                    target_path.display()
                )
            }),
            next_step: Some(format!(
                "Run your CLI's completion install command or place the file into {}.",
                quoted_completion_dir
            )),
        });
    }

    Ok(ActivationReport {
        mode: ActivationMode::ManagedRcBlock,
        availability: if wired {
            Availability::AvailableAfterSource
        } else {
            Availability::ManualActionRequired
        },
        location: Some(rc_path),
        reason: Some(if wired {
            "Managed zsh activation block is present.".to_owned()
        } else {
            "Completion file exists, but shellcomp did not find the managed zsh activation block."
                .to_owned()
        }),
        next_step: Some(if wired {
            format!("Run `source {quoted_rc_path}` or start a new Zsh session.")
        } else {
            format!(
                "Re-run installation or add {} to `fpath` and run `compinit -i`.",
                quoted_completion_dir
            )
        }),
    })
}

pub(crate) fn migrate(
    env: &Environment,
    program_name: &str,
    target_path: &Path,
    legacy_blocks: &[LegacyManagedBlock],
) -> Result<MigrationOutcome> {
    let rc_path = zshrc_path(env)?;
    let block = managed_block(program_name, target_path)?;
    let (legacy_change, managed_change) = migrate_profile_blocks(&rc_path, legacy_blocks, &block)?;

    Ok(MigrationOutcome {
        location: Some(rc_path.clone()),
        managed_change,
        legacy_change,
        affected_locations: vec![rc_path],
    })
}

fn managed_block(program_name: &str, target_path: &Path) -> Result<ManagedBlock> {
    let completion_dir = target_path.parent().ok_or_else(|| Error::PathHasNoParent {
        path: target_path.to_path_buf(),
    })?;
    let quoted = shell_quote(completion_dir)?;

    Ok(ManagedBlock {
        start_marker: format!("# >>> shellcomp zsh {program_name} >>>"),
        end_marker: format!("# <<< shellcomp zsh {program_name} <<<"),
        body: format!(
            "shellcomp_zfunc_dir={quoted}\nshellcomp_zfunc_changed=0\nif (( ${{fpath[(Ie)$shellcomp_zfunc_dir]}} == 0 )); then\n  fpath=(\"$shellcomp_zfunc_dir\" $fpath)\n  shellcomp_zfunc_changed=1\nfi\nautoload -Uz compinit\nif ! type compdef >/dev/null 2>&1 || (( shellcomp_zfunc_changed == 1 )); then\n  compinit -i\nfi\nunset shellcomp_zfunc_changed\nunset shellcomp_zfunc_dir"
        ),
    })
}

fn shell_quote(path: &Path) -> Result<String> {
    let value = path.to_str().ok_or_else(|| Error::NonUtf8Path {
        path: path.to_path_buf(),
    })?;
    Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
}

fn zshrc_path(env: &Environment) -> Result<PathBuf> {
    Ok(env.zdotdir()?.join(".zshrc"))
}

#[cfg(test)]
mod tests {
    use super::{detect, install, managed_block};
    use crate::infra::env::Environment;
    use crate::model::{ActivationMode, Availability};
    use std::path::Path;

    #[test]
    fn managed_block_uses_stable_zfunc_variable() {
        let block =
            managed_block("tool", Path::new("/tmp/home/.zfunc/_tool")).expect("block is valid");

        assert!(block.body.contains("shellcomp_zfunc_dir="));
        assert!(block.body.contains("shellcomp_zfunc_changed=0"));
        assert!(block.body.contains("type compdef"));
        assert!(block.body.contains("compinit -i"));
        assert!(block.body.contains("shellcomp_zfunc_changed == 1"));
        assert!(block.body.contains("unset shellcomp_zfunc_changed"));
        assert!(block.body.contains("unset shellcomp_zfunc_dir"));
    }

    #[test]
    fn install_errors_when_zshrc_is_not_writable() {
        let temp_root = crate::tests::temp_dir("install-zsh-manual-fallback");
        let home = temp_root.join("home");
        std::fs::create_dir_all(home.join(".zshrc")).expect("directory should be creatable");
        let env = Environment::test()
            .with_var("HOME", &home)
            .without_var("ZDOTDIR")
            .without_real_path_lookups();

        let error =
            install(&env, "tool", &home.join(".zfunc/_tool")).expect_err("install should fail");

        assert!(matches!(error, crate::Error::Io { .. }));
    }

    #[test]
    fn install_quotes_zshrc_path_in_next_step_when_zdotdir_has_spaces() {
        let temp_root = crate::tests::temp_dir("install-zsh-next-step");
        let home = temp_root.join("home");
        let zdotdir = temp_root.join("zdot dir");
        let env = Environment::test()
            .with_var("HOME", &home)
            .with_var("ZDOTDIR", &zdotdir)
            .without_real_path_lookups();

        let report =
            install(&env, "tool", &zdotdir.join(".zfunc/_tool")).expect("install should work");

        let next_step = report.report.next_step.expect("next_step should exist");
        assert!(next_step.contains("source '"));
        assert!(next_step.contains("zdot dir/.zshrc"));
    }

    #[test]
    fn detect_uses_actual_zdotdir_path_in_next_step() {
        let temp_root = crate::tests::temp_dir("detect-zsh-zdotdir-next-step");
        let home = temp_root.join("home");
        let zdotdir = temp_root.join("zdot dir");
        let target = zdotdir.join(".zfunc/_tool");
        std::fs::create_dir_all(target.parent().expect("target should have a parent"))
            .expect("target dir should be creatable");
        std::fs::write(&target, "#compdef tool\n").expect("target should be writable");
        let env = Environment::test()
            .with_var("HOME", &home)
            .with_var("ZDOTDIR", &zdotdir)
            .without_real_path_lookups();
        install(&env, "tool", &target).expect("install should work");

        let report = detect(&env, "tool", &target).expect("detect should work");

        assert_eq!(report.mode, ActivationMode::ManagedRcBlock);
        assert_eq!(report.availability, Availability::AvailableAfterSource);
        let next_step = report.next_step.expect("next_step should exist");
        assert!(next_step.contains("source '"));
        assert!(next_step.contains("zdot dir/.zshrc"));
        assert!(!next_step.contains("~/.zshrc"));
    }

    #[test]
    fn detect_unwired_guidance_uses_actual_zfunc_directory() {
        let temp_root = crate::tests::temp_dir("detect-zsh-unwired-guidance");
        let home = temp_root.join("home");
        let zdotdir = temp_root.join("zdot dir");
        let target = zdotdir.join(".zfunc/_tool");
        std::fs::create_dir_all(target.parent().expect("target should have a parent"))
            .expect("target dir should be creatable");
        std::fs::write(&target, "#compdef tool\n").expect("target should be writable");
        let env = Environment::test()
            .with_var("HOME", &home)
            .with_var("ZDOTDIR", &zdotdir)
            .without_real_path_lookups();

        let report = detect(&env, "tool", &target).expect("detect should work");

        assert_eq!(report.mode, ActivationMode::ManagedRcBlock);
        assert_eq!(report.availability, Availability::ManualActionRequired);
        let next_step = report.next_step.expect("next_step should exist");
        assert!(next_step.contains("fpath"));
        assert!(next_step.contains("zdot dir/.zfunc"));
        assert!(!next_step.contains("~/.zshrc"));
    }
}