Skip to content

Commit 200b09c

Browse files
committed
cli: Implement placeholder command loop and deterministic errors
Add a lexopt-based parser and dispatcher that routes help/setup/mcp/hooks/sync, rejects invalid invocation patterns with actionable messages, and normalizes runtime failures through anyhow with exit code 2.
1 parent d083a80 commit 200b09c

7 files changed

Lines changed: 170 additions & 18 deletions

File tree

cli/src/app.rs

Lines changed: 149 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,129 @@
11
use std::process::ExitCode;
22

3+
use anyhow::{bail, Result};
4+
use lexopt::{Arg, ValueExt};
5+
36
use crate::{command_surface, dependency_contract};
47

8+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
9+
enum Command {
10+
Help,
11+
Setup,
12+
Mcp,
13+
Hooks,
14+
Sync,
15+
}
16+
517
pub fn run<I>(args: I) -> ExitCode
18+
where
19+
I: IntoIterator<Item = String>,
20+
{
21+
match try_run(args) {
22+
Ok(()) => ExitCode::SUCCESS,
23+
Err(error) => {
24+
eprintln!("Error: {error}");
25+
ExitCode::from(2)
26+
}
27+
}
28+
}
29+
30+
fn try_run<I>(args: I) -> Result<()>
631
where
732
I: IntoIterator<Item = String>,
833
{
934
let _ = dependency_contract::dependency_contract_snapshot();
35+
let command = parse_command(args)?;
36+
dispatch(command)
37+
}
1038

39+
fn parse_command<I>(args: I) -> Result<Command>
40+
where
41+
I: IntoIterator<Item = String>,
42+
{
1143
let mut args = args.into_iter();
1244
let _program = args.next();
1345

14-
match args.next().as_deref() {
15-
None | Some("--help") | Some("-h") | Some("help") => {
16-
println!("{}", command_surface::help_text());
17-
ExitCode::SUCCESS
46+
let mut parser = lexopt::Parser::from_args(args);
47+
let mut command = None;
48+
49+
while let Some(arg) = parser.next()? {
50+
match arg {
51+
Arg::Long("help") | Arg::Short('h') => {
52+
if command.is_some() {
53+
bail!("'--help' must be used by itself. Run 'sce --help'.");
54+
}
55+
command = Some(Command::Help);
56+
}
57+
Arg::Value(value) => {
58+
let value = value.string()?;
59+
60+
if command.is_some() {
61+
bail!(
62+
"Unexpected extra argument '{}'. Run 'sce --help' to see valid usage.",
63+
value
64+
);
65+
}
66+
67+
command = Some(parse_subcommand(&value)?);
68+
}
69+
Arg::Long(option) => {
70+
bail!(
71+
"Unknown option '--{}'. Run 'sce --help' to see valid usage.",
72+
option
73+
);
74+
}
75+
Arg::Short(option) => {
76+
bail!(
77+
"Unknown option '-{}'. Run 'sce --help' to see valid usage.",
78+
option
79+
);
80+
}
1881
}
19-
Some(cmd) => {
20-
if command_surface::is_known_command(cmd) {
21-
println!(
22-
"'{}' is a planned placeholder command in this foundation slice.",
23-
cmd
82+
}
83+
84+
Ok(command.unwrap_or(Command::Help))
85+
}
86+
87+
fn parse_subcommand(value: &str) -> Result<Command> {
88+
match value {
89+
"help" => Ok(Command::Help),
90+
"setup" => Ok(Command::Setup),
91+
"mcp" => Ok(Command::Mcp),
92+
"hooks" => Ok(Command::Hooks),
93+
"sync" => Ok(Command::Sync),
94+
_ => {
95+
if command_surface::is_known_command(value) {
96+
bail!(
97+
"Command '{}' is currently unavailable in this build.",
98+
value
2499
);
25-
ExitCode::SUCCESS
26-
} else {
27-
eprintln!("Unknown command: {}", cmd);
28-
eprintln!("Run 'sce --help' to see the current placeholder command surface.");
29-
ExitCode::from(2)
30100
}
101+
102+
bail!(
103+
"Unknown command '{}'. Run 'sce --help' to see the current command surface.",
104+
value
105+
);
31106
}
32107
}
33108
}
34109

110+
fn dispatch(command: Command) -> Result<()> {
111+
match command {
112+
Command::Help => println!("{}", command_surface::help_text()),
113+
Command::Setup => println!("TODO: 'setup' is planned and not implemented yet."),
114+
Command::Mcp => println!("TODO: 'mcp' is planned and not implemented yet."),
115+
Command::Hooks => println!("TODO: 'hooks' is planned and not implemented yet."),
116+
Command::Sync => println!("TODO: 'sync' is planned and not implemented yet."),
117+
}
118+
119+
Ok(())
120+
}
121+
35122
#[cfg(test)]
36123
mod tests {
37124
use std::process::ExitCode;
38125

39-
use super::run;
126+
use super::{parse_command, run, Command};
40127

41128
#[test]
42129
fn help_path_exits_success() {
@@ -55,4 +142,51 @@ mod tests {
55142
let code = run(vec!["sce".to_string(), "does-not-exist".to_string()]);
56143
assert_eq!(code, ExitCode::from(2));
57144
}
145+
146+
#[test]
147+
fn parser_defaults_to_help_without_command() {
148+
let command = parse_command(vec!["sce".to_string()]).expect("command should parse");
149+
assert_eq!(command, Command::Help);
150+
}
151+
152+
#[test]
153+
fn parser_routes_placeholder_command() {
154+
let command = parse_command(vec!["sce".to_string(), "hooks".to_string()])
155+
.expect("command should parse");
156+
assert_eq!(command, Command::Hooks);
157+
}
158+
159+
#[test]
160+
fn parser_rejects_unknown_command() {
161+
let error = parse_command(vec!["sce".to_string(), "nope".to_string()])
162+
.expect_err("unknown command should fail");
163+
assert_eq!(
164+
error.to_string(),
165+
"Unknown command 'nope'. Run 'sce --help' to see the current command surface."
166+
);
167+
}
168+
169+
#[test]
170+
fn parser_rejects_unknown_option() {
171+
let error = parse_command(vec!["sce".to_string(), "--verbose".to_string()])
172+
.expect_err("unknown option should fail");
173+
assert_eq!(
174+
error.to_string(),
175+
"Unknown option '--verbose'. Run 'sce --help' to see valid usage."
176+
);
177+
}
178+
179+
#[test]
180+
fn parser_rejects_extra_arguments() {
181+
let error = parse_command(vec![
182+
"sce".to_string(),
183+
"setup".to_string(),
184+
"extra".to_string(),
185+
])
186+
.expect_err("extra argument should fail");
187+
assert_eq!(
188+
error.to_string(),
189+
"Unexpected extra argument 'extra'. Run 'sce --help' to see valid usage."
190+
);
191+
}
58192
}

context/architecture.md

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

context/cli/placeholder-foundation.md

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

context/glossary.md

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

context/overview.md

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

context/patterns.md

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

context/plans/sce-cli-placeholder-foundation.md

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)