projm 0.3.2

Project organizer and fuzzy navigator for developers — classify, group, and jump to projects from the terminal
Documentation
use anyhow::{Context, Result};
use colored::Colorize;
use std::{fs, path::PathBuf, process::Command};

use crate::completions;

const PROJM_BLOCK_START: &str = "# >>> projm >>>";
const PROJM_BLOCK_END: &str = "# <<< projm <<<";
const ZOXIDE_INIT_ZSH: &str = "eval \"$(zoxide init zsh)\"";
const ZOXIDE_INIT_POWERSHELL: &str = "Invoke-Expression (& { (zoxide init powershell | Out-String) })";

#[derive(Clone, Copy)]
enum InitTarget {
    Zsh,
    PowerShell,
}

pub fn run() -> Result<()> {
    let target = detect_target();

    eprintln!("[1/3] checking zoxide...");

    if has_zoxide() {
        eprintln!("      {} already installed", "".green().bold());
    } else {
        eprintln!("      not found");
        install_zoxide()?;
    }

    eprintln!("[2/3] writing completions...");
    let completion_file = completion_path(target);
    write_completions(target, &completion_file)?;
    eprintln!(
        "      {} {}",
        "".green().bold(),
        completion_file.display().to_string().dimmed()
    );

    eprintln!("[3/3] updating shell profile...");
    let profile = update_shell_profile(target)?;
    eprintln!("\n  {} updated {}", "done.".green().bold(), profile.display());

    Ok(())
}

fn detect_target() -> InitTarget {
    if std::env::consts::OS == "windows" {
        InitTarget::PowerShell
    } else {
        InitTarget::Zsh
    }
}

fn has_zoxide() -> bool {
    Command::new("zoxide")
        .arg("--version")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map_or(false, |s| s.success())
}

fn install_zoxide() -> Result<()> {
    let plan = installer_plan(std::env::consts::OS, os_release().as_deref());
    for cmd in &plan {
        eprintln!("      trying: {}", cmd.dimmed());
        if run_install_command(cmd) {
            eprintln!("      {} installed zoxide", "".green().bold());
            return Ok(());
        }
    }

    anyhow::bail!(
        "failed to install zoxide automatically. install it manually, then run `projm init` again"
    )
}

fn run_install_command(cmd: &str) -> bool {
    #[cfg(target_os = "windows")]
    {
        return Command::new("cmd")
            .args(["/C", cmd])
            .status()
            .map_or(false, |s| s.success());
    }

    #[cfg(not(target_os = "windows"))]
    {
        Command::new("sh")
            .args(["-c", cmd])
            .status()
            .map_or(false, |s| s.success())
    }
}

fn completion_path(target: InitTarget) -> PathBuf {
    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    match target {
        InitTarget::Zsh => home.join(".config/zsh/completions/_projm"),
        InitTarget::PowerShell => home.join(".config/powershell/completions/projm.ps1"),
    }
}

fn write_completions(target: InitTarget, path: &PathBuf) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    let script = match target {
        InitTarget::Zsh => completions::zsh_script()?,
        InitTarget::PowerShell => completions::powershell_script()?,
    };
    fs::write(path, script)?;
    Ok(())
}

fn update_shell_profile(target: InitTarget) -> Result<PathBuf> {
    let profile = shell_profile_path(target);

    if let Some(parent) = profile.parent() {
        fs::create_dir_all(parent)?;
    }

    let old = fs::read_to_string(&profile).unwrap_or_default();
    let updated = match target {
        InitTarget::Zsh => {
            let with_projm = ensure_projm_block_zsh(&old);
            ensure_line(&with_projm, ZOXIDE_INIT_ZSH)
        }
        InitTarget::PowerShell => {
            let with_projm = ensure_projm_block_powershell(&old);
            ensure_line(&with_projm, ZOXIDE_INIT_POWERSHELL)
        }
    };

    if updated != old {
        fs::write(&profile, updated).with_context(|| format!("write {}", profile.display()))?;
    }

    Ok(profile)
}

fn shell_profile_path(target: InitTarget) -> PathBuf {
    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
    match target {
        InitTarget::Zsh => home.join(".zshrc"),
        InitTarget::PowerShell => home.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"),
    }
}

fn ensure_projm_block_zsh(content: &str) -> String {
    if content.contains(PROJM_BLOCK_START) {
        return content.to_owned();
    }

    let block = format!(
        "{start}\npg() {{\n    local cmd\n    cmd=$(projm g 2>/dev/tty) || return\n    [ -n \"$cmd\" ] && eval \"$cmd\"\n}}\nfpath=(\"$HOME/.config/zsh/completions\" $fpath)\nautoload -Uz compinit && compinit\n{end}\n",
        start = PROJM_BLOCK_START,
        end = PROJM_BLOCK_END
    );

    if content.trim().is_empty() {
        block
    } else {
        format!("{}\n\n{}", content.trim_end(), block)
    }
}

fn ensure_projm_block_powershell(content: &str) -> String {
    if content.contains(PROJM_BLOCK_START) {
        return content.to_owned();
    }

    let block = format!(
        "{start}\nfunction pg {{\n  $cmd = projm g 2>$null\n  if ($cmd) {{ Invoke-Expression $cmd }}\n}}\n. \"$HOME/.config/powershell/completions/projm.ps1\"\n{end}\n",
        start = PROJM_BLOCK_START,
        end = PROJM_BLOCK_END
    );

    if content.trim().is_empty() {
        block
    } else {
        format!("{}\n\n{}", content.trim_end(), block)
    }
}

fn ensure_line(content: &str, line: &str) -> String {
    if content.lines().any(|l| l.trim() == line.trim()) {
        return content.to_owned();
    }
    if content.trim().is_empty() {
        format!("{}\n", line)
    } else {
        format!("{}\n{}\n", content.trim_end(), line)
    }
}

fn os_release() -> Option<String> {
    let data = fs::read_to_string("/etc/os-release").ok()?;
    Some(data.to_lowercase())
}

pub fn installer_plan(os: &str, os_release: Option<&str>) -> Vec<String> {
    if os == "windows" {
        return vec![
            "winget install --id ajeetdsouza.zoxide -e --source winget".into(),
            "choco install zoxide -y".into(),
            "scoop install zoxide".into(),
            "cargo install zoxide".into(),
        ];
    }

    if os == "macos" {
        return vec!["brew install zoxide".into(), "cargo install zoxide".into()];
    }

    if os == "linux" {
        let release = os_release.unwrap_or_default();
        if release.contains("arch") || release.contains("manjaro") {
            return vec![
                "pacman -S --noconfirm zoxide".into(),
                "yay -S --noconfirm zoxide".into(),
                "paru -S --noconfirm zoxide".into(),
                "cargo install zoxide".into(),
            ];
        }
        if release.contains("ubuntu") || release.contains("debian") {
            return vec![
                "apt update && apt install -y zoxide".into(),
                "cargo install zoxide".into(),
            ];
        }

        return vec!["cargo install zoxide".into()];
    }

    vec!["cargo install zoxide".into()]
}

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

    #[test]
    fn ensure_line_is_idempotent() {
        let a = ensure_line("hello", "world");
        let b = ensure_line(&a, "world");
        assert_eq!(a, b);
    }

    #[test]
    fn ensure_line_on_empty_content_has_no_leading_newline() {
        let line = ensure_line("", "world");
        assert_eq!(line, "world\n");
    }

    #[test]
    fn ensure_projm_block_is_idempotent() {
        let a = ensure_projm_block_zsh("");
        let b = ensure_projm_block_zsh(&a);
        assert_eq!(a, b);
    }

    #[test]
    fn ensure_powershell_block_is_idempotent() {
        let a = ensure_projm_block_powershell("");
        let b = ensure_projm_block_powershell(&a);
        assert_eq!(a, b);
    }

    #[test]
    fn arch_plan_prefers_pacman_then_helpers_then_cargo() {
        let plan = installer_plan("linux", Some("id=arch"));
        assert_eq!(
            plan,
            vec![
                "pacman -S --noconfirm zoxide",
                "yay -S --noconfirm zoxide",
                "paru -S --noconfirm zoxide",
                "cargo install zoxide"
            ]
        );
    }

    #[test]
    fn debian_plan_prefers_apt_then_cargo() {
        let plan = installer_plan("linux", Some("id=ubuntu"));
        assert_eq!(
            plan,
            vec![
                "apt update && apt install -y zoxide",
                "cargo install zoxide"
            ]
        );
    }

    #[test]
    fn windows_plan_prefers_winget_then_choco_then_scoop_then_cargo() {
        let plan = installer_plan("windows", None);
        assert_eq!(
            plan,
            vec![
                "winget install --id ajeetdsouza.zoxide -e --source winget",
                "choco install zoxide -y",
                "scoop install zoxide",
                "cargo install zoxide"
            ]
        );
    }
}