shellcomp 0.1.4

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

use shellcomp::{
    ActivationMode, ActivationPolicy, Availability, Error, FailureKind, FileChange, InstallRequest,
    Shell, UninstallRequest, default_install_path, detect_activation_at_path, install,
    install_with_policy, uninstall, uninstall_with_policy,
};

fn temp_dir(label: &str) -> PathBuf {
    let unique = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time should be after unix epoch")
        .as_nanos();
    let path = std::env::temp_dir().join(format!("shellcomp-it-{label}-{unique}"));
    std::fs::create_dir_all(&path).expect("temp dir should be creatable");
    path
}

#[test]
fn install_and_uninstall_roundtrip_via_public_api_with_path_override() {
    let temp_root = temp_dir("roundtrip");
    let target = temp_root.join("completions").join("demo.bash");
    let script = b"complete -F _demo demo\n";

    let first_install = install(InstallRequest {
        shell: Shell::Bash,
        program_name: "demo",
        script,
        path_override: Some(target.clone()),
    })
    .expect("first install should succeed");

    assert_eq!(first_install.shell, Shell::Bash);
    assert_eq!(first_install.target_path, target);
    assert_eq!(first_install.file_change, FileChange::Created);
    assert_eq!(first_install.activation.mode, ActivationMode::Manual);
    assert_eq!(
        first_install.activation.availability,
        Availability::ManualActionRequired
    );
    assert_eq!(
        std::fs::read(&first_install.target_path).expect("installed file should exist"),
        script
    );

    let second_install = install(InstallRequest {
        shell: Shell::Bash,
        program_name: "demo",
        script,
        path_override: Some(first_install.target_path.clone()),
    })
    .expect("second install should succeed");

    assert_eq!(second_install.file_change, FileChange::Unchanged);

    let first_uninstall = uninstall(UninstallRequest {
        shell: Shell::Bash,
        program_name: "demo",
        path_override: Some(second_install.target_path.clone()),
    })
    .expect("first uninstall should succeed");

    assert_eq!(first_uninstall.file_change, FileChange::Removed);
    assert_eq!(first_uninstall.cleanup.mode, ActivationMode::Manual);
    assert_eq!(first_uninstall.cleanup.change, FileChange::Absent);
    assert!(!first_uninstall.target_path.exists());

    let second_uninstall = uninstall(UninstallRequest {
        shell: Shell::Bash,
        program_name: "demo",
        path_override: Some(first_uninstall.target_path.clone()),
    })
    .expect("second uninstall should succeed");

    assert_eq!(second_uninstall.file_change, FileChange::Absent);
    assert_eq!(second_uninstall.cleanup.mode, ActivationMode::Manual);
    assert_eq!(second_uninstall.cleanup.change, FileChange::Absent);
}

#[test]
fn install_rejects_invalid_program_name_via_public_api() {
    let target = temp_dir("invalid-name").join("demo.bash");

    let error = install(InstallRequest {
        shell: Shell::Bash,
        program_name: "bad/name",
        script: b"complete -F _demo demo\n",
        path_override: Some(target),
    })
    .expect_err("invalid program name should fail");

    assert!(matches!(error, Error::InvalidProgramName { .. }));
    assert!(error.reason().is_some());
    assert!(error.next_step().is_some());
}

#[test]
fn install_returns_structured_failure_for_path_without_parent() {
    let error = install(InstallRequest {
        shell: Shell::Fish,
        program_name: "demo",
        script: b"complete -c demo\n",
        path_override: Some(PathBuf::from("/")),
    })
    .expect_err("path without parent should fail");

    match error {
        Error::Failure(report) => {
            assert_eq!(report.kind, FailureKind::InvalidTargetPath);
            assert_eq!(report.target_path.as_deref(), Some(Path::new("/")));
            assert_eq!(report.file_change, None);
        }
        other => panic!("unexpected error variant: {other}"),
    }
}

#[test]
fn default_install_path_rejects_unsupported_shell_via_public_api() {
    let error = default_install_path(Shell::Other("xonsh".to_owned()), "demo")
        .expect_err("unsupported shell should fail");

    assert!(matches!(
        error,
        Error::UnsupportedShell(Shell::Other(value)) if value == "xonsh"
    ));
}

#[test]
fn install_and_uninstall_with_policy_work_for_custom_fish_paths() {
    let temp_root = temp_dir("policy-roundtrip");
    let target = temp_root.join("completions").join("demo.fish");

    let install_report = install_with_policy(
        InstallRequest {
            shell: Shell::Fish,
            program_name: "demo",
            script: b"complete -c demo -f\n",
            path_override: Some(target.clone()),
        },
        ActivationPolicy::Manual,
    )
    .expect("install_with_policy should succeed");

    assert_eq!(install_report.file_change, FileChange::Created);
    assert_eq!(install_report.activation.mode, ActivationMode::Manual);

    let uninstall_report = uninstall_with_policy(
        UninstallRequest {
            shell: Shell::Fish,
            program_name: "demo",
            path_override: Some(target.clone()),
        },
        ActivationPolicy::Manual,
    )
    .expect("uninstall_with_policy should succeed");

    assert_eq!(uninstall_report.file_change, FileChange::Removed);
    assert_eq!(uninstall_report.cleanup.mode, ActivationMode::Manual);
    assert!(!target.exists());
}

#[test]
fn detect_activation_at_path_reports_status_for_custom_fish_path() {
    let temp_root = temp_dir("detect-at-path");
    let target = temp_root.join("completions").join("demo.fish");
    std::fs::create_dir_all(target.parent().expect("target should have a parent"))
        .expect("target dir should be creatable");
    std::fs::write(&target, "complete -c demo -f\n").expect("target file should be writable");

    let report = detect_activation_at_path(Shell::Fish, "demo", &target)
        .expect("detect_activation_at_path should succeed");

    assert_eq!(report.mode, ActivationMode::Manual);
    assert_eq!(report.availability, Availability::Unknown);
}

#[cfg(feature = "clap")]
mod clap_tests {
    use clap::Parser;
    use shellcomp::{Error, Shell, render_clap_completion};

    #[derive(Parser)]
    struct Cli {
        #[arg(long)]
        verbose: bool,
    }

    #[test]
    fn render_clap_completion_is_available_from_public_api() {
        let script = render_clap_completion::<Cli>(Shell::Fish, "demo")
            .expect("fish completion should render");
        let rendered = String::from_utf8(script).expect("rendered script should be utf-8");

        assert!(rendered.contains("demo"));
    }

    #[test]
    fn render_clap_completion_rejects_other_shell_via_public_api() {
        let error = render_clap_completion::<Cli>(Shell::Other("xonsh".to_owned()), "demo")
            .expect_err("unsupported shell should fail");

        assert!(matches!(
            error,
            Error::UnsupportedShell(Shell::Other(value)) if value == "xonsh"
        ));
    }
}