shellcomp 0.1.4

Shell completion installation and activation helpers for Rust CLI tools
Documentation
pub(crate) mod detect;
pub(crate) mod install;
pub(crate) mod uninstall;

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

use crate::error::{Error, Result};
use crate::model::{
    ActivationMode, ActivationPolicy, ActivationReport, Availability, CleanupReport, FailureKind,
    FailureReport, FileChange, Operation, Shell,
};

pub(crate) fn manual_activation_report(
    shell: &Shell,
    program_name: &str,
    target_path: &Path,
    custom_path: bool,
    activation_policy: ActivationPolicy,
) -> Result<ActivationReport> {
    let target = target_path.to_str().ok_or_else(|| Error::NonUtf8Path {
        path: target_path.to_path_buf(),
    })?;

    let next_step = match shell {
        Shell::Bash => format!(
            "Source `{target}` from your shell startup file, or move it into the standard bash-completion directory."
        ),
        Shell::Zsh => {
            let expected = format!("_{program_name}");
            if target_path.file_name().and_then(|value| value.to_str()) == Some(expected.as_str()) {
                format!(
                    "Ensure `{target}` is in a directory listed in `fpath`, then run `compinit -i`."
                )
            } else {
                format!(
                    "Rename the file to `{expected}` or load it manually, then ensure its directory is in `fpath` and run `compinit -i`."
                )
            }
        }
        Shell::Fish => {
            format!("Place `{target}` under Fish's completions directory or source it manually.")
        }
        Shell::Powershell => format!(
            "Add `. {}` to `$PROFILE.CurrentUserAllHosts` or another PowerShell profile.",
            powershell_quote(target)
        ),
        Shell::Elvish => {
            format!(
                "Evaluate `{target}` from your Elvish rc.elv. If you use a command such as `slurp`, make sure the installed path is quoted correctly for Elvish."
            )
        }
        Shell::Other(_) => format!("Activate `{target}` manually for `{shell}`."),
    };

    let reason = if custom_path && matches!(activation_policy, ActivationPolicy::Manual) {
        "A custom install path was provided, so shellcomp skipped automatic activation wiring."
            .to_owned()
    } else {
        match activation_policy {
            ActivationPolicy::AutoManaged => {
                "The shell does not support safe managed activation for this installation target, so manual activation is required."
                    .to_owned()
            }
            ActivationPolicy::Manual => {
            "Automatic activation wiring was skipped because the activation policy is manual."
                .to_owned()
            }
        }
    };

    Ok(ActivationReport {
        mode: ActivationMode::Manual,
        availability: Availability::ManualActionRequired,
        location: Some(target_path.to_path_buf()),
        reason: Some(reason),
        next_step: Some(next_step),
    })
}

pub(crate) fn push_unique(paths: &mut Vec<PathBuf>, path: impl Into<PathBuf>) {
    let path = path.into();
    if !paths.iter().any(|existing| existing == &path) {
        paths.push(path);
    }
}

pub(crate) fn zsh_target_is_autoloadable(program_name: &str, target_path: &Path) -> bool {
    let expected = format!("_{program_name}");
    target_path.file_name().and_then(|value| value.to_str()) == Some(expected.as_str())
}

fn powershell_quote(path: &str) -> String {
    format!("'{}'", path.replace('\'', "''"))
}

pub(crate) struct FailureContext<'a> {
    pub(crate) operation: Operation,
    pub(crate) shell: &'a Shell,
    pub(crate) target_path: Option<&'a Path>,
    pub(crate) affected_locations: Vec<PathBuf>,
    pub(crate) kind: FailureKind,
}

#[derive(Default)]
pub(crate) struct FailureStatus {
    pub(crate) file_change: Option<FileChange>,
    pub(crate) activation: Option<ActivationReport>,
    pub(crate) cleanup: Option<CleanupReport>,
}

pub(crate) fn failure(
    context: FailureContext<'_>,
    reason: impl Into<String>,
    next_step: Option<String>,
) -> Error {
    failure_with_status(context, FailureStatus::default(), reason, next_step)
}

pub(crate) fn failure_with_status(
    context: FailureContext<'_>,
    status: FailureStatus,
    reason: impl Into<String>,
    next_step: Option<String>,
) -> Error {
    Error::failure(FailureReport {
        operation: context.operation,
        shell: context.shell.clone(),
        target_path: context.target_path.map(Path::to_path_buf),
        affected_locations: context.affected_locations,
        kind: context.kind,
        file_change: status.file_change,
        activation: status.activation,
        cleanup: status.cleanup,
        reason: reason.into(),
        next_step,
    })
}