decompose 0.1.1

A simple and flexible scheduler and orchestrator to manage non-containerized applications
Documentation
//! Shell completion generation.
//!
//! `decompose completion <shell>` prints a ready-to-source completion script
//! for the given shell. Scripts are generated by `clap_complete` from the
//! clap `Command` tree, then post-processed for bash/zsh to add dynamic
//! service-name completion that shells out to `decompose config --json`
//! at completion time.

use std::io;

use anyhow::{Context, Result};
use clap::CommandFactory;
use clap_complete::{Shell, generate};

use crate::cli::{Cli, CompletionShell};

const BIN_NAME: &str = "decompose";

/// Subcommands whose positional arguments are service names. The dynamic
/// completion injected into the bash/zsh scripts completes these by
/// invoking `decompose config --json`.
const SERVICE_CMDS: &[&str] = &[
    "start", "stop", "restart", "kill", "logs", "exec", "run", "up",
];

pub fn run_completion(shell: CompletionShell) -> Result<()> {
    let clap_shell = to_clap_shell(shell);
    let mut cmd = Cli::command();
    let mut buf: Vec<u8> = Vec::new();
    generate(clap_shell, &mut cmd, BIN_NAME, &mut buf);
    let script = String::from_utf8(buf).context("completion script was not valid UTF-8")?;
    let final_script = match shell {
        CompletionShell::Bash => inject_bash_dynamic(&script),
        CompletionShell::Zsh => inject_zsh_dynamic(&script),
        _ => script,
    };
    use io::Write as _;
    let stdout = io::stdout();
    let mut lock = stdout.lock();
    lock.write_all(final_script.as_bytes())
        .context("failed to write completion script")?;
    Ok(())
}

fn to_clap_shell(shell: CompletionShell) -> Shell {
    match shell {
        CompletionShell::Bash => Shell::Bash,
        CompletionShell::Zsh => Shell::Zsh,
        CompletionShell::Fish => Shell::Fish,
        CompletionShell::PowerShell => Shell::PowerShell,
        CompletionShell::Elvish => Shell::Elvish,
    }
}

/// Append a small helper + wrapper at the end of the generated bash script
/// that expands service-name completions by calling `decompose config
/// --json`. We override `COMPREPLY` when the current subcommand is one of
/// `SERVICE_CMDS` and the current word doesn't start with `-`.
fn inject_bash_dynamic(script: &str) -> String {
    let svc_list = SERVICE_CMDS
        .iter()
        .map(|s| format!("\"{s}\""))
        .collect::<Vec<_>>()
        .join(" ");
    let snippet = BASH_DYNAMIC_SNIPPET.replace("__SVC_LIST__", &svc_list);
    format!("{script}{snippet}")
}

/// Append a zsh helper that re-registers `decompose` with a wrapper calling
/// the clap-generated `_decompose` function first and then, if completing a
/// positional arg on a service-taking subcommand, offering service names
/// from `decompose config --json`.
fn inject_zsh_dynamic(script: &str) -> String {
    let svc_list = SERVICE_CMDS.join(" ");
    let snippet = ZSH_DYNAMIC_SNIPPET.replace("__SVC_LIST__", &svc_list);
    format!("{script}{snippet}")
}

const BASH_DYNAMIC_SNIPPET: &str = r#"
# --- decompose dynamic service completion (injected) -------------------------
__decompose_services() {
    local svcs
    if command -v jq >/dev/null 2>&1; then
        svcs=$(decompose config --json 2>/dev/null | jq -r '.processes | keys[]' 2>/dev/null)
    else
        svcs=$(decompose config --json 2>/dev/null \
            | sed -n 's/^  *"\([A-Za-z0-9_][A-Za-z0-9_-]*\)": *{.*/\1/p')
    fi
    COMPREPLY=( $(compgen -W "${svcs}" -- "${cur}") )
}

__decompose_wrap() {
    local cur subcmd i
    cur="${COMP_WORDS[COMP_CWORD]}"
    subcmd=""
    for (( i=1; i<COMP_CWORD; i++ )); do
        case "${COMP_WORDS[i]}" in
            -*) continue ;;
            *) subcmd="${COMP_WORDS[i]}"; break ;;
        esac
    done
    case " __SVC_LIST__ " in
        *" \"${subcmd}\" "*)
            if [[ "${cur}" != -* ]]; then
                __decompose_services
                return 0
            fi
            ;;
    esac
    # Fall back to the clap-generated completion function.
    if declare -F _decompose >/dev/null 2>&1; then
        _decompose
    fi
}
complete -F __decompose_wrap -o bashdefault -o default decompose
# -----------------------------------------------------------------------------
"#;

const ZSH_DYNAMIC_SNIPPET: &str = r#"
# --- decompose dynamic service completion (injected) -------------------------
__decompose_services() {
    local -a svcs
    local raw
    raw=$(decompose config --json 2>/dev/null) || return 0
    if (( $+commands[jq] )); then
        svcs=(${(f)"$(print -r -- "$raw" | jq -r '.processes | keys[]' 2>/dev/null)"})
    else
        svcs=(${(f)"$(print -r -- "$raw" | sed -n 's/^  *"\([A-Za-z0-9_][A-Za-z0-9_-]*\)": *{.*/\1/p')"})
    fi
    if (( ${#svcs} )); then
        _values 'service' "${svcs[@]}"
    fi
}

__decompose_dyn_wrap() {
    local -a service_cmds
    service_cmds=(__SVC_LIST__)
    local sub
    local i
    for (( i=2; i<=${#words}; i++ )); do
        case "${words[i]}" in
            -*) continue ;;
            *) sub="${words[i]}"; break ;;
        esac
    done
    if [[ -n "$sub" && "${service_cmds[(r)$sub]}" == "$sub" && "${words[CURRENT]}" != -* ]]; then
        __decompose_services
        return 0
    fi
    _decompose "$@"
}
compdef __decompose_dyn_wrap decompose
# -----------------------------------------------------------------------------
"#;