flk 0.6.3

A CLI tool for managing flake.nix devShell environments
Documentation
//! # Completions Command Handler
//!
//! Generate and install shell completions for bash, zsh, fish, and more.

use anyhow::{anyhow, Result};
use clap::CommandFactory;
use clap_complete::{generate, generate_to, shells::Shell};
use std::{env, fs, io, path::PathBuf};

use crate::Cli;

/// Generate or install shell completions.
///
/// # Arguments
///
/// * `install` - If true, install completions to the appropriate location
/// * `shell` - Target shell (auto-detected if not provided)
pub fn handle_completions(install: bool, shell: Option<Shell>) -> Result<()> {
    let mut cmd = Cli::command();
    let bin_name = cmd.get_name().to_string();

    // Detect shell automatically if not provided
    let shell = shell.or_else(detect_shell).unwrap_or(Shell::Bash);

    if install {
        let path = get_completion_install_path(shell)?;
        fs::create_dir_all(path.parent().unwrap())?;
        generate_to(shell, &mut cmd, bin_name.clone(), path.parent().unwrap())?;
        println!(
            "✅ Installed completions for {shell:?} at {}",
            path.display()
        );
        print_post_install_message(shell);
    } else {
        generate(shell, &mut cmd, bin_name, &mut io::stdout());
    }

    Ok(())
}

/// Try to detect the current shell from the environment
fn detect_shell() -> Option<Shell> {
    env::var("SHELL").ok().and_then(|path| {
        if path.contains("bash") {
            Some(Shell::Bash)
        } else if path.contains("zsh") {
            Some(Shell::Zsh)
        } else if path.contains("fish") {
            Some(Shell::Fish)
        } else if path.contains("elvish") {
            Some(Shell::Elvish)
        } else if path.contains("powershell") {
            Some(Shell::PowerShell)
        } else {
            None
        }
    })
}

/// Figure out where to install the completion script depending on shell
fn get_completion_install_path(shell: Shell) -> Result<PathBuf> {
    let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not detect home directory"))?;

    let path = match shell {
        Shell::Bash => home.join(".local/share/bash-completion/completions/flk"),
        Shell::Zsh => home.join(".zsh/completions/_flk"),
        Shell::Fish => home.join(".config/fish/completions/flk.fish"),
        Shell::PowerShell => home.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
        Shell::Elvish => home.join(".config/elvish/lib/completions/flk.elv"),
        _ => return Err(anyhow!("Unsupported shell for auto-install")),
    };

    Ok(path)
}

/// Print helpful info after installing completions
fn print_post_install_message(shell: Shell) {
    match shell {
        Shell::Zsh => println!(
            "\nℹ️  Make sure your ~/.zshrc contains:\n  fpath+=~/.zsh/completions\n  autoload -Uz compinit && compinit"
        ),
        Shell::Bash => println!(
            "\nℹ️  You may need to reload your shell or run:\n  source ~/.bashrc"
        ),
        Shell::Fish => println!(
            "\nℹ️  Restart your terminal or run:\n  exec fish"
        ),
        _ => (),
    }
}

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

    fn with_shell_env<F: FnOnce()>(value: Option<&str>, f: F) {
        // SAFETY: tests in this module run serially via #[serial] is not used;
        // we rely on this being the only place mutating SHELL.
        let prev = env::var("SHELL").ok();
        match value {
            Some(v) => unsafe { env::set_var("SHELL", v) },
            None => unsafe { env::remove_var("SHELL") },
        }
        f();
        match prev {
            Some(v) => unsafe { env::set_var("SHELL", v) },
            None => unsafe { env::remove_var("SHELL") },
        }
    }

    #[test]
    fn detect_shell_recognises_known_shells() {
        with_shell_env(Some("/bin/bash"), || {
            assert_eq!(detect_shell(), Some(Shell::Bash));
        });
        with_shell_env(Some("/usr/bin/zsh"), || {
            assert_eq!(detect_shell(), Some(Shell::Zsh));
        });
        with_shell_env(Some("/opt/homebrew/bin/fish"), || {
            assert_eq!(detect_shell(), Some(Shell::Fish));
        });
        with_shell_env(Some("/usr/local/bin/elvish"), || {
            assert_eq!(detect_shell(), Some(Shell::Elvish));
        });
        with_shell_env(Some("pwsh-or-powershell"), || {
            assert_eq!(detect_shell(), Some(Shell::PowerShell));
        });
    }

    #[test]
    fn detect_shell_returns_none_for_unknown_or_missing() {
        with_shell_env(Some("/bin/tcsh"), || {
            assert_eq!(detect_shell(), None);
        });
        with_shell_env(None, || {
            assert_eq!(detect_shell(), None);
        });
    }

    #[test]
    fn install_paths_are_shell_specific_and_under_home() {
        let home = dirs::home_dir().expect("test environment must have a home dir");

        let bash = get_completion_install_path(Shell::Bash).unwrap();
        assert!(bash.starts_with(&home));
        assert!(bash.ends_with("flk"));
        assert!(bash.to_string_lossy().contains("bash-completion"));

        let zsh = get_completion_install_path(Shell::Zsh).unwrap();
        assert!(
            zsh.ends_with("_flk"),
            "zsh completions need leading underscore"
        );

        let fish = get_completion_install_path(Shell::Fish).unwrap();
        assert!(fish.extension().is_some_and(|e| e == "fish"));

        let elvish = get_completion_install_path(Shell::Elvish).unwrap();
        assert!(elvish.extension().is_some_and(|e| e == "elv"));

        let pwsh = get_completion_install_path(Shell::PowerShell).unwrap();
        assert!(pwsh.to_string_lossy().contains("PowerShell"));
    }

    #[test]
    fn all_currently_known_shells_have_install_paths() {
        // clap_complete::Shell is #[non_exhaustive]. If a future version adds a
        // new variant, this test will compile-fail on the match below and force
        // a deliberate decision about whether to support it.
        for shell in [
            Shell::Bash,
            Shell::Zsh,
            Shell::Fish,
            Shell::Elvish,
            Shell::PowerShell,
        ] {
            assert!(
                get_completion_install_path(shell).is_ok(),
                "{shell:?} should resolve to an install path"
            );
        }
    }
}