specman_cli/
cli.rs

1use std::ffi::OsString;
2use std::process::ExitCode;
3
4use clap::{Arg, ArgAction, ArgMatches, Command};
5
6use crate::commands;
7use crate::context::CliSession;
8use crate::error::{CliError, ExitStatus};
9use crate::formatter::{OutputFormat, emit_result};
10use crate::util::Verbosity;
11
12const NAME: &str = "specman";
13
14pub fn run() -> ExitCode {
15    init_tracing();
16    match run_cli(std::env::args()) {
17        Ok(code) => code,
18        Err(err) => {
19            err.print();
20            err.exit_code()
21        }
22    }
23}
24
25/// Parses CLI arguments, resolves the workspace, and dispatches to the appropriate
26/// command while enforcing the CLI Invocation Model (spec/specman-cli/spec.md#concept-cli-invocation-model).
27/// Returns a POSIX `sysexits`-compatible `ExitCode` so automation can react deterministically.
28pub fn run_cli<I, S>(args: I) -> Result<ExitCode, CliError>
29where
30    I: IntoIterator<Item = S>,
31    S: Into<OsString> + Clone,
32{
33    let command = build_cli();
34    let matches = command.try_get_matches_from(args)?;
35
36    let verbosity = Verbosity {
37        json: matches.get_flag("json"),
38        verbose: matches.get_flag("verbose"),
39    };
40    let output = if verbosity.json {
41        OutputFormat::Json
42    } else {
43        OutputFormat::Text
44    };
45
46    let workspace_override = matches.get_one::<String>("workspace").cloned();
47
48    let result = dispatch(&matches, workspace_override, verbosity)?;
49    emit_result(result, output)
50}
51
52fn init_tracing() {
53    let _ = tracing_subscriber::fmt()
54        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
55        .try_init();
56}
57
58/// Defines the root `clap::Command` tree, including global flags and subcommands for
59/// `status`, `spec`, `impl`, and `scratch`. Keeping the tree centralized ensures every
60/// command advertises its help text per the CLI Invocation Model requirements.
61fn build_cli() -> Command {
62    Command::new(NAME)
63        .about("SpecMan CLI")
64        .arg(
65            Arg::new("workspace")
66                .long("workspace")
67                .value_name("PATH")
68                .help("Specify the workspace root. Defaults to the nearest ancestor with a .specman folder."),
69        )
70        .arg(
71            Arg::new("json")
72                .long("json")
73                .action(ArgAction::SetTrue)
74                .help("Emit newline-delimited JSON instead of human-readable text."),
75        )
76        .arg(
77            Arg::new("verbose")
78                .long("verbose")
79                .action(ArgAction::SetTrue)
80                .help("Emit additional logging about template locators, workspace paths, and adapters."),
81        )
82        .subcommand_required(true)
83        .subcommand(commands::init::command())
84        .subcommand(commands::status::command())
85        .subcommand(commands::spec::command())
86        .subcommand(commands::implementation::command())
87        .subcommand(commands::scratch::command())
88        .subcommand(commands::templates::command())
89}
90
91/// Delegates parsed subcommands to their respective modules, ensuring the Lifecycle
92/// Command Surface stays thin and predictable. Unknown subcommands map to `EX_USAGE` so
93/// callers receive actionable feedback.
94fn dispatch(
95    matches: &ArgMatches,
96    workspace_override: Option<String>,
97    verbosity: Verbosity,
98) -> Result<commands::CommandResult, CliError> {
99    match matches.subcommand() {
100        Some(("init", sub)) => commands::init::run(workspace_override, sub),
101        _ => {
102            let session = CliSession::bootstrap(workspace_override, verbosity)?;
103            if session.verbosity.verbose {
104                tracing::info!(
105                    workspace = %session.workspace_paths.root().display(),
106                    spec_dir = %session.workspace_paths.spec_dir().display(),
107                    impl_dir = %session.workspace_paths.impl_dir().display(),
108                    scratch_dir = %session.workspace_paths.scratchpad_dir().display(),
109                    "resolved workspace context"
110                );
111            }
112            dispatch_with_session(&session, matches)
113        }
114    }
115}
116
117fn dispatch_with_session(
118    session: &CliSession,
119    matches: &ArgMatches,
120) -> Result<commands::CommandResult, CliError> {
121    match matches.subcommand() {
122        Some(("status", sub)) => commands::status::run(session, sub),
123        Some(("spec", sub)) => commands::spec::run(session, sub),
124        Some(("impl", sub)) => commands::implementation::run(session, sub),
125        Some(("scratch", sub)) => commands::scratch::run(session, sub),
126        Some(("template", sub)) => commands::templates::run(session, sub),
127        _ => Err(CliError::new("missing command", ExitStatus::Usage)),
128    }
129}