clap-ext 0.1.0

Shared Rust CLI extension library: common subcommands, config flags, error display for clap-based CLIs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
//! Port trait and `clap`-backed adapter for CLI invocations.
//!
//! This module introduces the [`CliPort`] abstraction: a domain-agnostic
//! interface for parsing a command-line invocation into a structured
//! [`ParsedInvocation`]. The port is intentionally small — it knows about
//! the common global flags and the three common subcommands that
//! `clap-ext` standardizes, but says nothing about `clap` itself.
//!
//! [`ClapBasedCli`] is the canonical adapter: it implements [`CliPort`]
//! on top of the `clap` builder API. Other adapters (e.g. a `pico-args`
//! or `lexopt` implementation, or an in-memory mock for tests) can be
//! slotted in by implementing [`CliPort`] against a different backend.
//!
//! ## Adding app-specific subcommands
//!
//! Use [`ClapBasedCli::with_subcommand`] to register an app-specific
//! subcommand. The adapter does not interpret the subcommand's trailing
//! positional args; it captures them verbatim into
//! [`ParsedCli::Other::args`] so the application can dispatch on them.
//!
//! ## Example
//!
//! ```
//! use clap_ext::clap_based_cli::{CliPort, ClapBasedCli};
//!
//! let cli = ClapBasedCli::new("mycli", "demo", "1.2.3")
//!     .with_subcommand("serve", "Start the server", "Server args");
//!
//! let inv = cli.parse(&["serve", "8080"]).unwrap();
//! assert_eq!(inv.globals.verbose, 0);
//! match inv.command {
//!     clap_ext::clap_based_cli::ParsedCli::Other { name, args } => {
//!         assert_eq!(name, "serve");
//!         assert_eq!(args, vec!["8080".to_string()]);
//!     }
//!     _ => panic!("expected Other"),
//! }
//! ```

use std::path::PathBuf;

use crate::common_args::OutputFormat;
use crate::common_subcommands::add_common_subcommands;
use crate::error::CliError;

// ---------------------------------------------------------------------------
// Port trait
// ---------------------------------------------------------------------------

/// Port for parsing a command-line invocation into a structured form.
///
/// `CliPort` is the seam between an application's domain logic and the
/// concrete argument parser. The trait surface is intentionally narrow:
///
/// - [`CliPort::parse`] turns a slice of argv (without `argv[0]`) into
///   a [`ParsedInvocation`].
/// - [`CliPort::help`] / [`CliPort::version`] / [`CliPort::name`] expose
///   the metadata an application needs to render its own `--help`
///   page, build shell-completion stubs, or report `--version`.
///
/// `Send + Sync` lets implementations live behind an `Arc<dyn CliPort>`
/// in long-running processes (REPLs, daemons, MCP servers).
pub trait CliPort: Send + Sync {
    /// Parse the given args (excluding the program name) into a
    /// [`ParsedInvocation`]. Returns [`CliError::Parse`] on failure.
    fn parse(&self, args: &[&str]) -> Result<ParsedInvocation, CliError>;

    /// Render the long help text for this CLI.
    fn help(&self) -> String;

    /// The version string reported by `--version` and the
    /// [`ParsedCli::Version`] subcommand.
    fn version(&self) -> &str;

    /// The binary name (used as `argv[0]` and in help banners).
    fn name(&self) -> &str;
}

// ---------------------------------------------------------------------------
// Domain types
// ---------------------------------------------------------------------------

/// Global flags shared across all subcommands.
///
/// Mirrors the fields on [`crate::common_args::ConfigArg`] and
/// [`crate::common_args::Verbosity`] plus [`crate::common_args::OutputFormat`],
/// flattened into a single struct so the port is independent of the
/// derive macros that produce them.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GlobalOptions {
    /// `-c/--config` value (`PHENOTYPE_CONFIG` env var honored).
    pub config: Option<PathBuf>,
    /// `-v/--verbose` count (each `-v` adds 1).
    pub verbose: u8,
    /// `-q/--quiet` flag.
    pub quiet: bool,
    /// `--output` value (default [`OutputFormat::Human`]).
    pub output: OutputFormat,
}

/// The subcommand portion of a parsed invocation.
///
/// The three "common" subcommands from [`crate::common_subcommands`]
/// are first-class variants. App-specific subcommands registered via
/// [`ClapBasedCli::with_subcommand`] land in [`ParsedCli::Other`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParsedCli {
    /// `init [path] [-f] [-t <template>]`
    Init {
        path: PathBuf,
        force: bool,
        template: String,
    },
    /// `validate <path> [--strict]`
    Validate { path: PathBuf, strict: bool },
    /// `version`
    Version,
    /// App-specific subcommand. `args` are the trailing positional
    /// args captured after the subcommand name; the application is
    /// responsible for further parsing.
    Other { name: String, args: Vec<String> },
}

/// A fully-parsed CLI invocation: global flags + a subcommand.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedInvocation {
    pub globals: GlobalOptions,
    pub command: ParsedCli,
}

// ---------------------------------------------------------------------------
// clap adapter
// ---------------------------------------------------------------------------

/// [`CliPort`] implementation backed by the `clap` builder API.
///
/// Owns the metadata (name, about, version) and the list of custom
/// subcommands; the three common subcommands (`init`, `validate`,
/// `version`) are always available via [`crate::common_subcommands`].
pub struct ClapBasedCli {
    name: String,
    about: String,
    version: String,
    author: Option<String>,
    /// `(name, about, help)` tuples for app-specific subcommands.
    custom: Vec<(String, String, String)>,
}

impl ClapBasedCli {
    /// Create a new CLI with the given name, about, and version.
    pub fn new(
        name: impl Into<String>,
        about: impl Into<String>,
        version: impl Into<String>,
    ) -> Self {
        Self {
            name: name.into(),
            about: about.into(),
            version: version.into(),
            author: None,
            custom: Vec::new(),
        }
    }

    /// Set the `--author` field shown in the long help.
    pub fn with_author(mut self, author: impl Into<String>) -> Self {
        self.author = Some(author.into());
        self
    }

    /// Register an app-specific subcommand.
    ///
    /// The subcommand's parsed form will be delivered to the consumer
    /// as [`ParsedCli::Other`] with the captured trailing positional
    /// `args`. Custom subcommands are listed before the common ones in
    /// `--help` output so that an app's main commands appear first.
    pub fn with_subcommand(
        mut self,
        name: impl Into<String>,
        about: impl Into<String>,
        help: impl Into<String>,
    ) -> Self {
        self.custom.push((name.into(), about.into(), help.into()));
        self
    }

    /// Borrow the metadata for inspection (e.g. logging, telemetry).
    pub fn name(&self) -> &str {
        &self.name
    }
    pub fn version(&self) -> &str {
        &self.version
    }
    pub fn about(&self) -> &str {
        &self.about
    }

    /// Build the underlying [`clap::Command`]. Exposed for testing
    /// and for callers that need to extend the command further.
    pub fn build_command(&self) -> clap::Command {
        let mut cmd = clap::Command::new(self.name.clone())
            .about(self.about.clone())
            .version(self.version.clone())
            .subcommand_required(true)
            .arg_required_else_help(true)
            .arg(
                clap::Arg::new("config")
                    .short('c')
                    .long("config")
                    .value_name("CONFIG")
                    .help("Path to the config file")
                    .env("PHENOTYPE_CONFIG")
                    .value_parser(clap::value_parser!(PathBuf)),
            )
            .arg(
                clap::Arg::new("verbose")
                    .short('v')
                    .long("verbose")
                    .action(clap::ArgAction::Count)
                    .help("Increase log verbosity (-v, -vv, -vvv)"),
            )
            .arg(
                clap::Arg::new("quiet")
                    .short('q')
                    .long("quiet")
                    .action(clap::ArgAction::SetTrue)
                    .conflicts_with("verbose")
                    .help("Suppress non-error output"),
            )
            .arg(
                clap::Arg::new("output")
                    .long("output")
                    .value_name("OUTPUT")
                    .value_parser(clap::value_parser!(OutputFormat))
                    .default_value("human")
                    .help("Output format (human, json, yaml)"),
            );

        if let Some(author) = &self.author {
            cmd = cmd.author(author.clone());
        }

        for (name, about, help) in &self.custom {
            cmd = cmd.subcommand(
                clap::Command::new(name.clone()).about(about.clone()).arg(
                    clap::Arg::new("args")
                        .num_args(0..)
                        .trailing_var_arg(true)
                        .allow_hyphen_values(true)
                        .help(help.clone()),
                ),
            );
        }

        add_common_subcommands(cmd)
    }
}

impl CliPort for ClapBasedCli {
    fn parse(&self, args: &[&str]) -> Result<ParsedInvocation, CliError> {
        // clap expects argv[0] to be the program name.
        let mut full: Vec<&str> = Vec::with_capacity(args.len() + 1);
        full.push(&self.name);
        full.extend(args.iter().copied());

        let matches = self
            .build_command()
            .try_get_matches_from(&full)
            .map_err(|e| CliError::Parse(e.to_string()))?;

        let globals = GlobalOptions {
            config: matches.get_one::<PathBuf>("config").cloned(),
            verbose: matches.get_count("verbose"),
            quiet: matches.get_flag("quiet"),
            output: *matches
                .get_one::<OutputFormat>("output")
                .unwrap_or(&OutputFormat::Human),
        };

        let command = match matches.subcommand() {
            Some(("init", m)) => ParsedCli::Init {
                path: m
                    .get_one::<PathBuf>("path")
                    .cloned()
                    .unwrap_or_else(|| PathBuf::from(".")),
                force: m.get_flag("force"),
                template: m
                    .get_one::<String>("template")
                    .cloned()
                    .unwrap_or_else(|| "default".to_string()),
            },
            Some(("validate", m)) => {
                let path = m.get_one::<PathBuf>("path").cloned().ok_or_else(|| {
                    CliError::Parse("validate requires a <path> argument".to_string())
                })?;
                ParsedCli::Validate {
                    path,
                    strict: m.get_flag("strict"),
                }
            }
            Some(("version", _)) => ParsedCli::Version,
            Some((name, m)) => {
                let args: Vec<String> = m
                    .get_many::<String>("args")
                    .map(|v| v.cloned().collect())
                    .unwrap_or_default();
                ParsedCli::Other {
                    name: name.to_string(),
                    args,
                }
            }
            None => {
                return Err(CliError::Parse(
                    "a subcommand is required (try --help)".to_string(),
                ));
            }
        };

        Ok(ParsedInvocation { globals, command })
    }

    fn help(&self) -> String {
        // `render_help()` writes the help text to a `StyledStr` that
        // we can convert to a `String`. Disabling color keeps the
        // output deterministic for tests and embedding in docs.
        self.build_command()
            .color(clap::ColorChoice::Never)
            .render_help()
            .to_string()
    }

    fn version(&self) -> &str {
        &self.version
    }

    fn name(&self) -> &str {
        &self.name
    }
}

// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn cli() -> ClapBasedCli {
        ClapBasedCli::new("test-cli", "test about", "0.1.0")
            .with_author("Phenotype")
            .with_subcommand("serve", "Start server", "Server args")
    }

    #[test]
    fn parses_init_with_defaults() {
        let inv = cli().parse(&["init"]).unwrap();
        assert_eq!(inv.globals.verbose, 0);
        assert!(!inv.globals.quiet);
        assert_eq!(inv.globals.output, OutputFormat::Human);
        assert_eq!(
            inv.command,
            ParsedCli::Init {
                path: PathBuf::from("."),
                force: false,
                template: "default".to_string(),
            }
        );
    }

    #[test]
    fn parses_init_with_overrides() {
        let inv = cli()
            .parse(&["init", "/tmp/proj", "-f", "-t", "rust"])
            .unwrap();
        assert_eq!(
            inv.command,
            ParsedCli::Init {
                path: PathBuf::from("/tmp/proj"),
                force: true,
                template: "rust".to_string(),
            }
        );
    }

    #[test]
    fn parses_validate_strict() {
        let inv = cli().parse(&["validate", "/etc/cfg", "--strict"]).unwrap();
        assert_eq!(
            inv.command,
            ParsedCli::Validate {
                path: PathBuf::from("/etc/cfg"),
                strict: true,
            }
        );
    }

    #[test]
    fn validate_requires_path() {
        let err = cli().parse(&["validate"]).unwrap_err();
        assert!(matches!(err, CliError::Parse(_)));
    }

    #[test]
    fn parses_version_subcommand() {
        let inv = cli().parse(&["version"]).unwrap();
        assert_eq!(inv.command, ParsedCli::Version);
    }

    #[test]
    fn parses_custom_subcommand() {
        let inv = cli().parse(&["serve", "8080", "--workers", "4"]).unwrap();
        match inv.command {
            ParsedCli::Other { name, args } => {
                assert_eq!(name, "serve");
                assert_eq!(args, vec!["8080", "--workers", "4"]);
            }
            _ => panic!("expected Other, got {:?}", inv.command),
        }
    }

    #[test]
    fn parses_global_flags() {
        let inv = cli()
            .parse(&["-vv", "-c", "/tmp/cfg.yaml", "--output", "json", "version"])
            .unwrap();
        assert_eq!(inv.globals.verbose, 2);
        assert_eq!(inv.globals.config, Some(PathBuf::from("/tmp/cfg.yaml")));
        assert_eq!(inv.globals.output, OutputFormat::Json);
        assert_eq!(inv.command, ParsedCli::Version);
    }

    #[test]
    fn quiet_conflicts_with_verbose() {
        // clap should reject `-v -q`; we surface that as CliError::Parse.
        let err = cli().parse(&["-v", "-q", "version"]).unwrap_err();
        assert!(matches!(err, CliError::Parse(_)));
    }

    #[test]
    fn missing_subcommand_errors() {
        let err = cli().parse(&[]).unwrap_err();
        assert!(matches!(err, CliError::Parse(_)));
    }

    #[test]
    fn help_contains_subcommands_and_flags() {
        let h = cli().help();
        assert!(h.contains("init"), "help should list init; got: {h}");
        assert!(
            h.contains("validate"),
            "help should list validate; got: {h}"
        );
        assert!(h.contains("version"), "help should list version; got: {h}");
        assert!(
            h.contains("serve"),
            "help should list custom subcommand; got: {h}"
        );
        assert!(
            h.contains("--config"),
            "help should describe --config; got: {h}"
        );
        assert!(
            h.contains("--verbose"),
            "help should describe --verbose; got: {h}"
        );
    }

    #[test]
    fn metadata_accessors() {
        let c = cli();
        assert_eq!(c.name(), "test-cli");
        assert_eq!(c.version(), "0.1.0");
        assert_eq!(c.about(), "test about");
        // Trait accessors agree with the inherent ones.
        let port: &dyn CliPort = &c;
        assert_eq!(port.name(), "test-cli");
        assert_eq!(port.version(), "0.1.0");
    }

    #[test]
    fn trait_is_object_safe() {
        // Compile-time check: we can put it behind an Arc<dyn CliPort>.
        let c: std::sync::Arc<dyn CliPort> = std::sync::Arc::new(cli());
        let inv = c.parse(&["version"]).unwrap();
        assert_eq!(inv.command, ParsedCli::Version);
    }
}