Skip to content

Commit 7d9206c

Browse files
authored
feat(nodeup): implement completions command and sync docs (#287)
## Summary - implement `nodeup completions <shell> [command]` using `clap_complete` - support `bash`, `zsh`, `fish`, `powershell`, `elvish` - validate optional command scope against top-level commands and return `invalid-input` on invalid shell/scope - keep completions output as raw stdout script regardless of `--output` mode - add structured success/failure logs for completion generation - update nodeup docs/contracts/public docs to match behavior ## Tests - cargo test -p nodeup - cargo test
1 parent 84a6371 commit 7d9206c

8 files changed

Lines changed: 343 additions & 20 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/public-docs/nodeup.mdx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- Runtime selection: `default`, `override set|unset|list`, `show active-runtime`
1515
- Runtime-aware execution: `run`, `which`
1616
- Self-management: `self update|uninstall|upgrade-data`
17+
- Shell completion generation: `completions <shell> [command]`
1718

1819
## Install
1920

@@ -68,6 +69,23 @@ nodeup override set lts --path /path/to/project
6869
- `--output human|json` is available for management commands.
6970
- Human mode uses pretty `tracing` logs by default.
7071
- JSON mode writes machine payloads to stdout and keeps logs off by default unless explicitly enabled.
72+
- `completions` always writes raw completion scripts to stdout, even when `--output json` is set.
73+
74+
## Shell completions
75+
76+
Supported shells:
77+
78+
- `bash`
79+
- `zsh`
80+
- `fish`
81+
- `powershell`
82+
- `elvish`
83+
84+
Command scope behavior:
85+
86+
- `nodeup completions <shell>` generates completion output for all top-level commands.
87+
- `nodeup completions <shell> <command>` only accepts top-level command scopes (`toolchain`, `default`, `show`, `update`, `check`, `override`, `which`, `run`, `self`, `completions`).
88+
- Invalid shell or scope values fail with `invalid-input`.
7189

7290
## Reliability and validation
7391

crates/nodeup/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ readme = "README.md"
88

99
[dependencies]
1010
clap = { version = "4.5.32", features = ["derive"] }
11+
clap_complete = "4.5.47"
1112
reqwest = { version = "0.12.14", default-features = false, features = ["blocking", "json", "rustls-tls"] }
1213
semver = { version = "1.0.26", features = ["serde"] }
1314
serde = { version = "1.0.219", features = ["derive"] }

crates/nodeup/README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,32 @@ If no selector resolves, commands fail with deterministic `not-found` errors.
5151
- handled failures are written to stderr as JSON envelopes
5252
- fields: `kind`, `message`, `exit_code`
5353
- default logging is off unless explicitly enabled via `RUST_LOG`
54+
- `completions` command:
55+
- always writes raw completion script text to stdout
56+
- does not wrap completion output in JSON, even when `--output json` is set
5457

5558
Color control:
5659

5760
- `NODEUP_LOG_COLOR=always|auto|never` (default `always`)
5861
- `NO_COLOR` disables color when `NODEUP_LOG_COLOR` is unset or `auto`
5962

63+
## Completions
64+
65+
`nodeup completions` generates shell completion scripts for:
66+
67+
- `bash`
68+
- `zsh`
69+
- `fish`
70+
- `powershell`
71+
- `elvish`
72+
73+
Scope filtering:
74+
75+
- `nodeup completions <shell>` generates completions for all top-level commands.
76+
- `nodeup completions <shell> <command>` accepts only top-level command scopes:
77+
- `toolchain`, `default`, `show`, `update`, `check`, `override`, `which`, `run`, `self`, `completions`
78+
- invalid scopes fail with `invalid-input`.
79+
6080
## Testing Strategy
6181

6282
`nodeup` validation combines unit tests and end-to-end CLI integration tests.
Lines changed: 205 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,215 @@
1+
use std::io::Write;
2+
3+
use clap::CommandFactory;
4+
use clap_complete::{generate, Shell};
15
use tracing::info;
26

3-
use crate::errors::{NodeupError, Result};
7+
use crate::{
8+
cli::Cli,
9+
errors::{NodeupError, Result},
10+
};
11+
12+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13+
enum CompletionShell {
14+
Bash,
15+
Zsh,
16+
Fish,
17+
PowerShell,
18+
Elvish,
19+
}
20+
21+
impl CompletionShell {
22+
fn parse(raw: &str) -> Result<Self> {
23+
match raw.trim().to_ascii_lowercase().as_str() {
24+
"bash" => Ok(Self::Bash),
25+
"zsh" => Ok(Self::Zsh),
26+
"fish" => Ok(Self::Fish),
27+
"powershell" => Ok(Self::PowerShell),
28+
"elvish" => Ok(Self::Elvish),
29+
_ => Err(NodeupError::invalid_input(format!(
30+
"Unsupported shell '{raw}'. Supported shells: bash, zsh, fish, powershell, elvish"
31+
))),
32+
}
33+
}
34+
35+
fn as_str(self) -> &'static str {
36+
match self {
37+
Self::Bash => "bash",
38+
Self::Zsh => "zsh",
39+
Self::Fish => "fish",
40+
Self::PowerShell => "powershell",
41+
Self::Elvish => "elvish",
42+
}
43+
}
44+
45+
fn to_clap_shell(self) -> Shell {
46+
match self {
47+
Self::Bash => Shell::Bash,
48+
Self::Zsh => Shell::Zsh,
49+
Self::Fish => Shell::Fish,
50+
Self::PowerShell => Shell::PowerShell,
51+
Self::Elvish => Shell::Elvish,
52+
}
53+
}
54+
}
55+
56+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57+
enum CompletionScope {
58+
Toolchain,
59+
Default,
60+
Show,
61+
Update,
62+
Check,
63+
Override,
64+
Which,
65+
Run,
66+
SelfCmd,
67+
Completions,
68+
}
69+
70+
impl CompletionScope {
71+
fn parse(raw: &str) -> Result<Self> {
72+
match raw.trim() {
73+
"toolchain" => Ok(Self::Toolchain),
74+
"default" => Ok(Self::Default),
75+
"show" => Ok(Self::Show),
76+
"update" => Ok(Self::Update),
77+
"check" => Ok(Self::Check),
78+
"override" => Ok(Self::Override),
79+
"which" => Ok(Self::Which),
80+
"run" => Ok(Self::Run),
81+
"self" => Ok(Self::SelfCmd),
82+
"completions" => Ok(Self::Completions),
83+
_ => Err(NodeupError::invalid_input(format!(
84+
"Unsupported command scope '{raw}'. Supported top-level commands: toolchain, \
85+
default, show, update, check, override, which, run, self, completions"
86+
))),
87+
}
88+
}
89+
90+
fn as_str(self) -> &'static str {
91+
match self {
92+
Self::Toolchain => "toolchain",
93+
Self::Default => "default",
94+
Self::Show => "show",
95+
Self::Update => "update",
96+
Self::Check => "check",
97+
Self::Override => "override",
98+
Self::Which => "which",
99+
Self::Run => "run",
100+
Self::SelfCmd => "self",
101+
Self::Completions => "completions",
102+
}
103+
}
104+
}
4105

5106
pub fn completions(shell: &str, command: Option<&str>) -> Result<i32> {
6-
let scope = command.unwrap_or("<all-commands>");
107+
let scope_label = command.unwrap_or("<all-commands>");
108+
109+
let parsed_shell = CompletionShell::parse(shell).inspect_err(|error| {
110+
log_generation_failure(shell, scope_label, "invalid-shell", error);
111+
})?;
112+
113+
let parsed_scope = command
114+
.map(CompletionScope::parse)
115+
.transpose()
116+
.inspect_err(|error| {
117+
log_generation_failure(parsed_shell.as_str(), scope_label, "invalid-scope", error);
118+
})?;
119+
120+
let script = generate_completion_script(parsed_shell, parsed_scope).inspect_err(|error| {
121+
log_generation_failure(
122+
parsed_shell.as_str(),
123+
scope_label,
124+
"generation-failed",
125+
error,
126+
);
127+
})?;
128+
129+
let mut stdout = std::io::stdout();
130+
stdout.write_all(&script).map_err(|error| {
131+
let nodeup_error = NodeupError::internal(format!(
132+
"Failed to write completion script to stdout: {error}"
133+
));
134+
log_generation_failure(
135+
parsed_shell.as_str(),
136+
scope_label,
137+
"stdout-write-failed",
138+
&nodeup_error,
139+
);
140+
nodeup_error
141+
})?;
142+
stdout.flush().map_err(|error| {
143+
let nodeup_error =
144+
NodeupError::internal(format!("Failed to flush completion script output: {error}"));
145+
log_generation_failure(
146+
parsed_shell.as_str(),
147+
scope_label,
148+
"stdout-flush-failed",
149+
&nodeup_error,
150+
);
151+
nodeup_error
152+
})?;
153+
154+
info!(
155+
command_path = "nodeup.completions",
156+
action = "generate",
157+
shell = parsed_shell.as_str(),
158+
scope = scope_label,
159+
scope_present = parsed_scope.is_some(),
160+
outcome = "generated",
161+
"Generated completion script"
162+
);
163+
164+
Ok(0)
165+
}
166+
167+
fn generate_completion_script(
168+
shell: CompletionShell,
169+
scope: Option<CompletionScope>,
170+
) -> Result<Vec<u8>> {
171+
let mut root = Cli::command();
172+
if let Some(scope) = scope {
173+
apply_scope(&mut root, scope)?;
174+
}
175+
176+
let mut buffer = Vec::new();
177+
generate(shell.to_clap_shell(), &mut root, "nodeup", &mut buffer);
178+
Ok(buffer)
179+
}
180+
181+
fn apply_scope(root: &mut clap::Command, scope: CompletionScope) -> Result<()> {
182+
let selected = scope.as_str();
183+
if !root
184+
.get_subcommands()
185+
.any(|subcommand| subcommand.get_name() == selected)
186+
{
187+
return Err(NodeupError::invalid_input(format!(
188+
"Unsupported command scope '{selected}'"
189+
)));
190+
}
191+
192+
*root = root.clone().mut_subcommands(|subcommand| {
193+
if subcommand.get_name() == selected {
194+
subcommand
195+
} else {
196+
subcommand.hide(true)
197+
}
198+
});
199+
200+
Ok(())
201+
}
202+
203+
fn log_generation_failure(shell: &str, scope: &str, reason: &str, error: &NodeupError) {
7204
info!(
8205
command_path = "nodeup.completions",
9206
action = "generate",
10207
shell,
11-
scope_present = command.is_some(),
12-
outcome = "not-implemented",
13-
"Completions generation command is not implemented yet"
208+
scope,
209+
scope_present = scope != "<all-commands>",
210+
outcome = "failed",
211+
reason,
212+
error = %error.message,
213+
"Failed to generate completion script"
14214
);
15-
Err(NodeupError::not_implemented(format!(
16-
"nodeup completions for shell '{shell}' and scope '{scope}' is planned for the next phase"
17-
)))
18215
}

0 commit comments

Comments
 (0)