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";
pub(crate) 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),
CompletionShell::Fish => inject_fish_dynamic(&script),
CompletionShell::PowerShell => inject_powershell_dynamic(&script),
CompletionShell::Elvish => 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,
}
}
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}")
}
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}")
}
fn inject_fish_dynamic(script: &str) -> String {
let svc_list = SERVICE_CMDS.join(" ");
let snippet = FISH_DYNAMIC_SNIPPET.replace("__SVC_LIST__", &svc_list);
format!("{script}{snippet}")
}
fn inject_powershell_dynamic(script: &str) -> String {
let svc_list = SERVICE_CMDS
.iter()
.map(|s| format!("'{s}'"))
.collect::<Vec<_>>()
.join(", ");
let snippet = POWERSHELL_DYNAMIC_SNIPPET.replace("__SVC_LIST__", &svc_list);
format!("{script}{snippet}")
}
const BASH_DYNAMIC_SNIPPET: &str = r#"
# --- decompose dynamic service completion (injected) -------------------------
# Walk COMP_WORDS[1..COMP_CWORD-1] and collect the pre-subcommand global
# flags (--file, -e/--env-file, --session/--project-name, --disable-dotenv)
# so we can forward them to `decompose config --json` / `decompose ls --json`.
# Stops at the first non-flag token (the subcommand).
#
# Populates the global array __DECOMPOSE_GLOBALS and the global string
# __DECOMPOSE_SUBCMD.
__decompose_collect_globals() {
__DECOMPOSE_GLOBALS=()
__DECOMPOSE_SUBCMD=""
local i=1 tok
while (( i < COMP_CWORD )); do
tok="${COMP_WORDS[i]}"
case "$tok" in
--file=*|--session=*|--project-name=*|--env-file=*)
__DECOMPOSE_GLOBALS+=("$tok")
;;
--file|--session|--project-name|--env-file|-e)
__DECOMPOSE_GLOBALS+=("$tok")
if (( i + 1 < COMP_CWORD )); then
(( i++ ))
__DECOMPOSE_GLOBALS+=("${COMP_WORDS[i]}")
fi
;;
--disable-dotenv)
__DECOMPOSE_GLOBALS+=("$tok")
;;
-*)
# Unknown flag — keep scanning; might take a value but we
# don't know, so just treat the token itself as forwarded
# context and continue.
;;
*)
__DECOMPOSE_SUBCMD="$tok"
return 0
;;
esac
(( i++ ))
done
return 0
}
__decompose_services() {
local svcs raw
raw=$(decompose "${__DECOMPOSE_GLOBALS[@]}" config --json 2>/dev/null) || return 0
if command -v jq >/dev/null 2>&1; then
svcs=$(printf '%s' "$raw" | jq -r '.processes | keys[]' 2>/dev/null)
else
svcs=$(printf '%s' "$raw" \
| sed -n 's/^ *"\([A-Za-z0-9_][A-Za-z0-9_-]*\)": *{.*/\1/p')
fi
COMPREPLY=( $(compgen -W "${svcs}" -- "${cur}") )
}
__decompose_sessions() {
local names raw
raw=$(decompose ls --json 2>/dev/null) || return 0
if command -v jq >/dev/null 2>&1; then
names=$(printf '%s' "$raw" | jq -r '.environments[]?.name' 2>/dev/null)
else
names=$(printf '%s' "$raw" \
| sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
fi
COMPREPLY=( $(compgen -W "${names}" -- "${cur}") )
}
__decompose_wrap() {
local cur prev
cur="${COMP_WORDS[COMP_CWORD]}"
prev=""
if (( COMP_CWORD > 0 )); then
prev="${COMP_WORDS[COMP_CWORD-1]}"
fi
# Session value: `--session <TAB>`, `--project-name <TAB>`, or `--session=<TAB>`.
case "$prev" in
--session|--project-name)
__decompose_sessions
return 0
;;
esac
case "$cur" in
--session=*|--project-name=*)
local raw="${cur#*=}"
local names
local _saved="$cur"
cur="$raw"
__decompose_sessions
cur="$_saved"
# Re-prepend the flag= prefix to each candidate.
local prefix="${_saved%%=*}="
local i
for (( i=0; i<${#COMPREPLY[@]}; i++ )); do
COMPREPLY[i]="${prefix}${COMPREPLY[i]}"
done
return 0
;;
esac
__decompose_collect_globals
case " __SVC_LIST__ " in
*" \"${__DECOMPOSE_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) -------------------------
# Walk $words[2..CURRENT-1] and collect pre-subcommand global flags.
# Populates the global array __decompose_globals and string __decompose_sub.
__decompose_collect_globals() {
__decompose_globals=()
__decompose_sub=""
local i=2 tok
while (( i < CURRENT )); do
tok="${words[i]}"
case "$tok" in
--file=*|--session=*|--project-name=*|--env-file=*)
__decompose_globals+=("$tok")
;;
--file|--session|--project-name|--env-file|-e)
__decompose_globals+=("$tok")
if (( i + 1 < CURRENT )); then
(( i++ ))
__decompose_globals+=("${words[i]}")
fi
;;
--disable-dotenv)
__decompose_globals+=("$tok")
;;
-*)
;;
*)
__decompose_sub="$tok"
return 0
;;
esac
(( i++ ))
done
return 0
}
__decompose_services() {
local -a svcs
local raw
raw=$(decompose "${__decompose_globals[@]}" 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_sessions() {
local -a names
local raw
raw=$(decompose ls --json 2>/dev/null) || return 0
if (( $+commands[jq] )); then
names=(${(f)"$(print -r -- "$raw" | jq -r '.environments[]?.name' 2>/dev/null)"})
else
names=(${(f)"$(print -r -- "$raw" | sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')"})
fi
if (( ${#names} )); then
_values 'session' "${names[@]}"
fi
}
__decompose_dyn_wrap() {
local -a service_cmds
service_cmds=(__SVC_LIST__)
local -a __decompose_globals
local __decompose_sub=""
# `--session <TAB>` / `--project-name <TAB>`.
if (( CURRENT >= 2 )); then
local prev="${words[CURRENT-1]}"
case "$prev" in
--session|--project-name)
__decompose_sessions
return 0
;;
esac
fi
# `--session=<TAB>` / `--project-name=<TAB>`.
case "${words[CURRENT]}" in
--session=*|--project-name=*)
__decompose_sessions
return 0
;;
esac
__decompose_collect_globals
if [[ -n "$__decompose_sub" && "${service_cmds[(r)$__decompose_sub]}" == "$__decompose_sub" && "${words[CURRENT]}" != -* ]]; then
__decompose_services
return 0
fi
_decompose "$@"
}
compdef __decompose_dyn_wrap decompose
# -----------------------------------------------------------------------------
"#;
const FISH_DYNAMIC_SNIPPET: &str = r#"
# --- decompose dynamic service completion (injected) -------------------------
# Walk the current command line and echo the pre-subcommand global flags
# (one token per line) so they can be forwarded to `decompose config --json`.
function __decompose_collect_globals
set -l tokens (commandline -opc)
set -l n (count $tokens)
set -l i 2
while test $i -le $n
set -l tok $tokens[$i]
switch $tok
case '--file=*' '--session=*' '--project-name=*' '--env-file=*'
echo -- $tok
case '--file' '--session' '--project-name' '--env-file' '-e'
echo -- $tok
set i (math $i + 1)
if test $i -le $n
echo -- $tokens[$i]
end
case '--disable-dotenv'
echo -- $tok
case '-*'
# unknown flag, skip
case '*'
return 0
end
set i (math $i + 1)
end
end
function __decompose_services
set -l globals (__decompose_collect_globals)
set -l raw (decompose $globals config --json 2>/dev/null)
if test -z "$raw"
return 0
end
if type -q jq
printf '%s' $raw | jq -r '.processes | keys[]' 2>/dev/null
else
printf '%s' $raw | sed -n 's/^ *"\([A-Za-z0-9_][A-Za-z0-9_-]*\)": *{.*/\1/p'
end
end
function __decompose_sessions
set -l raw (decompose ls --json 2>/dev/null)
if test -z "$raw"
return 0
end
if type -q jq
printf '%s' $raw | jq -r '.environments[]?.name' 2>/dev/null
else
printf '%s' $raw | sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
end
end
# Service-name completion for each service-taking subcommand.
complete -c decompose -n '__fish_seen_subcommand_from __SVC_LIST__' -f -a '(__decompose_services)'
# Session-name completion for --session / --project-name (long-form values).
complete -c decompose -l session -f -a '(__decompose_sessions)'
complete -c decompose -l project-name -f -a '(__decompose_sessions)'
# -----------------------------------------------------------------------------
"#;
const POWERSHELL_DYNAMIC_SNIPPET: &str = r#"
# --- decompose dynamic service completion (injected) -------------------------
function __DecomposeCollectGlobals {
param([string[]]$Words)
$globals = @()
# $Words[0] is typically the command name; scan the rest for pre-subcommand
# global flags. Stop at the first non-flag token.
for ($i = 1; $i -lt $Words.Length; $i++) {
$t = $Words[$i]
switch -Regex ($t) {
'^(--file|--session|--project-name|--env-file)=.*$' { $globals += $t; continue }
'^(--file|--session|--project-name|--env-file|-e)$' {
$globals += $t
if ($i + 1 -lt $Words.Length) { $i++; $globals += $Words[$i] }
continue
}
'^--disable-dotenv$' { $globals += $t; continue }
'^-' { continue }
default { return ,$globals }
}
}
return ,$globals
}
function __DecomposeServices {
param([string[]]$Words)
try {
$globals = __DecomposeCollectGlobals $Words
$raw = & decompose @globals config --json 2>$null
if (-not $raw) { return @() }
$data = $raw | ConvertFrom-Json -ErrorAction Stop
if ($null -ne $data.processes) {
return @($data.processes.PSObject.Properties | ForEach-Object { $_.Name })
}
} catch { }
return @()
}
function __DecomposeSessions {
try {
$raw = & decompose ls --json 2>$null
if (-not $raw) { return @() }
$data = $raw | ConvertFrom-Json -ErrorAction Stop
if ($null -ne $data.environments) {
return @($data.environments | ForEach-Object { $_.name })
}
} catch { }
return @()
}
Register-ArgumentCompleter -Native -CommandName decompose -ScriptBlock {
param($wordToComplete, $commandAst, $cursorPosition)
$tokens = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
$svcCmds = @(__SVC_LIST__)
# Determine the current subcommand (first non-flag token after the binary).
$subcmd = $null
for ($i = 1; $i -lt $tokens.Length; $i++) {
$t = $tokens[$i]
if ($t -notlike '-*') { $subcmd = $t; break }
}
# Session value completion.
if ($tokens.Length -ge 2) {
$prev = $tokens[$tokens.Length - 2]
if ($prev -eq '--session' -or $prev -eq '--project-name') {
return @(__DecomposeSessions) `
| Where-Object { $_ -like "$wordToComplete*" } `
| ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
}
if ($wordToComplete -match '^(--session|--project-name)=(.*)$') {
$prefix = $Matches[1] + '='
$value = $Matches[2]
return @(__DecomposeSessions) `
| Where-Object { $_ -like "$value*" } `
| ForEach-Object {
$t = $prefix + $_
[System.Management.Automation.CompletionResult]::new($t, $t, 'ParameterValue', $t)
}
}
# Service-name completion for service-taking subcommands.
if ($null -ne $subcmd -and $svcCmds -contains $subcmd -and $wordToComplete -notlike '-*') {
return @(__DecomposeServices $tokens) `
| Where-Object { $_ -like "$wordToComplete*" } `
| ForEach-Object {
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
}
}
# No dynamic match — let the default clap-generated completer handle it.
return @()
}
# -----------------------------------------------------------------------------
"#;
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn service_cmds_are_real_subcommands() {
let cmd = Cli::command();
let names: Vec<String> = cmd
.get_subcommands()
.map(|c| c.get_name().to_string())
.collect();
for svc in SERVICE_CMDS {
assert!(
names.iter().any(|n| n == svc),
"SERVICE_CMDS entry {svc:?} has no matching clap subcommand (found: {names:?})"
);
}
}
#[test]
fn service_cmds_list_matches_known_shape() {
let expected = [
"start", "stop", "restart", "kill", "logs", "exec", "run", "up",
];
assert_eq!(SERVICE_CMDS, &expected);
}
#[test]
fn bash_snippet_mentions_flag_forwarding_and_sessions() {
let s = BASH_DYNAMIC_SNIPPET;
assert!(s.contains("__decompose_collect_globals"));
assert!(s.contains("__decompose_sessions"));
assert!(s.contains("decompose \"${__DECOMPOSE_GLOBALS[@]}\" config --json"));
}
#[test]
fn zsh_snippet_mentions_flag_forwarding_and_sessions() {
let s = ZSH_DYNAMIC_SNIPPET;
assert!(s.contains("__decompose_collect_globals"));
assert!(s.contains("__decompose_sessions"));
assert!(s.contains("decompose \"${__decompose_globals[@]}\" config --json"));
}
#[test]
fn fish_snippet_mentions_flag_forwarding_and_sessions() {
let s = FISH_DYNAMIC_SNIPPET;
assert!(s.contains("__decompose_collect_globals"));
assert!(s.contains("__decompose_sessions"));
assert!(s.contains("__fish_seen_subcommand_from"));
}
#[test]
fn powershell_snippet_mentions_flag_forwarding_and_sessions() {
let s = POWERSHELL_DYNAMIC_SNIPPET;
assert!(s.contains("__DecomposeCollectGlobals"));
assert!(s.contains("__DecomposeSessions"));
assert!(s.contains("Register-ArgumentCompleter"));
}
}