node-app-build 6.4.3

Mini app developer CLI: scaffold, validate, package node-app-* Debian packages
//! `node-app completions generate|install`

use anyhow::{bail, Context, Result};
use clap_complete::{generate, Shell};
use std::fs;
use std::path::{Path, PathBuf};

/// Print the completion script for `shell` to stdout.
pub fn generate_to_stdout(shell: Shell, cmd: &mut clap::Command) {
    generate(shell, cmd, "node-app", &mut std::io::stdout());
}

/// Install the completion script for `shell` (auto-detected if `None`) to the
/// canonical per-user path, and patch the shell's rc file if needed.
pub fn install(shell: Option<Shell>, cmd: &mut clap::Command) -> Result<()> {
    let shell = resolve_shell(shell)?;
    let home = home_dir()?;
    let path = install_path(shell, &home)?;

    let mut buf = Vec::new();
    generate(shell, cmd, "node-app", &mut buf);

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create {}", parent.display()))?;
    }
    fs::write(&path, &buf)
        .with_context(|| format!("write {}", path.display()))?;

    println!("Completions installed → {}", path.display());

    patch_rc(shell, &home, &path)?;

    Ok(())
}

// ── rc patching ──────────────────────────────────────────────────────────────

/// Patch the shell's rc file so the completion is loaded on every new shell.
/// Idempotent: does nothing if the required snippet is already present.
fn patch_rc(shell: Shell, home: &Path, completion_path: &Path) -> Result<()> {
    match shell {
        Shell::Bash => patch_file(
            &home.join(".bashrc"),
            "# node-app completions",
            &format!(
                "# node-app completions\nsource \"{}\"",
                completion_path.display()
            ),
        ),
        Shell::Zsh => {
            // ~/.zfunc must be in $fpath before compinit runs.
            // Insert at the very top of .zshrc so it precedes any plugin manager
            // (zinit, oh-my-zsh, etc.) that calls compinit internally.
            let zshrc = home.join(".zshrc");
            let fpath_dir = home.join(".zfunc");
            let marker = "# node-app: user completions fpath";
            let snippet = format!(
                "{marker}\nfpath=(\"{fpath}\" $fpath)",
                fpath = fpath_dir.display()
            );
            prepend_if_missing(&zshrc, marker, &snippet)
        }
        Shell::Fish => {
            // Fish auto-loads ~/.config/fish/completions/ — nothing to patch.
            println!("Fish auto-loads completions; no rc change needed.");
            Ok(())
        }
        Shell::PowerShell => patch_file(
            &home.join(".config/powershell/Microsoft.PowerShell_profile.ps1"),
            "# node-app completions",
            &format!(
                "# node-app completions\n. \"{}\"",
                completion_path.display()
            ),
        ),
        Shell::Elvish => patch_file(
            &home.join(".config/elvish/rc.elv"),
            "# node-app completions",
            "# node-app completions\nuse completions/node-app",
        ),
        _ => Ok(()),
    }
}

/// Append `snippet` to `path` unless `marker` is already present in the file.
fn patch_file(path: &Path, marker: &str, snippet: &str) -> Result<()> {
    let existing = fs::read_to_string(path).unwrap_or_default();
    if existing.contains(marker) {
        println!("rc already configured ({})", path.display());
        return Ok(());
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create {}", parent.display()))?;
    }
    let mut content = existing;
    if !content.ends_with('\n') && !content.is_empty() {
        content.push('\n');
    }
    content.push('\n');
    content.push_str(snippet);
    content.push('\n');
    fs::write(path, &content).with_context(|| format!("write {}", path.display()))?;
    println!("Updated {}", path.display());
    Ok(())
}

/// Prepend `snippet` to the top of `path` unless `marker` is already present.
/// Used for zsh: fpath must come before any plugin manager that calls compinit.
fn prepend_if_missing(path: &Path, marker: &str, snippet: &str) -> Result<()> {
    let existing = fs::read_to_string(path).unwrap_or_default();
    if existing.contains(marker) {
        println!("rc already configured ({})", path.display());
        return Ok(());
    }
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("create {}", parent.display()))?;
    }
    let content = format!("{snippet}\n\n{existing}");
    fs::write(path, &content).with_context(|| format!("write {}", path.display()))?;
    println!("Updated {} (open a new terminal to activate)", path.display());
    Ok(())
}

// ── helpers ──────────────────────────────────────────────────────────────────

fn resolve_shell(shell: Option<Shell>) -> Result<Shell> {
    if let Some(s) = shell {
        return Ok(s);
    }
    let shell_path = std::env::var("SHELL")
        .context("$SHELL is not set; pass --shell bash|zsh|fish|powershell|elvish")?;
    let name = Path::new(&shell_path)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or("");
    match name {
        "bash" => Ok(Shell::Bash),
        "zsh" => Ok(Shell::Zsh),
        "fish" => Ok(Shell::Fish),
        "pwsh" | "powershell" => Ok(Shell::PowerShell),
        "elvish" | "elv" => Ok(Shell::Elvish),
        other => bail!(
            "unrecognised shell '{other}'; pass --shell bash|zsh|fish|powershell|elvish"
        ),
    }
}

fn home_dir() -> Result<PathBuf> {
    std::env::var("HOME")
        .map(PathBuf::from)
        .context("$HOME is not set")
}

fn xdg_config(home: &Path) -> PathBuf {
    std::env::var("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| home.join(".config"))
}

fn xdg_data(home: &Path) -> PathBuf {
    std::env::var("XDG_DATA_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| home.join(".local/share"))
}

fn install_path(shell: Shell, home: &Path) -> Result<PathBuf> {
    Ok(match shell {
        // bash-completion 2.x auto-sources this directory.
        Shell::Bash => xdg_data(home).join("bash-completion/completions/node-app"),
        // zsh fpath convention.
        Shell::Zsh => home.join(".zfunc/_node-app"),
        // fish auto-loads every file in this directory.
        Shell::Fish => xdg_config(home).join("fish/completions/node-app.fish"),
        // Standalone .ps1 sourced from $PROFILE.
        Shell::PowerShell => xdg_data(home).join("node-app/completions/node-app.ps1"),
        // elvish XDG data lib path.
        Shell::Elvish => xdg_data(home).join("elvish/lib/completions/node-app.elv"),
        _ => bail!(
            "unsupported shell for auto-install; use `completions generate` and install manually"
        ),
    })
}