Skip to main content

agent_first_data/
cli.rs

1#[cfg(any(feature = "cli", feature = "cli-help"))]
2use crate::protocol::build_cli_error;
3use crate::protocol::{
4    BuildError, Event, LogLevel, ProtocolViolation, json_error, json_log, json_progress,
5    json_result, validate_protocol_event,
6};
7use crate::redaction::OutputOptions;
8use serde_json::Value;
9
10// ═══════════════════════════════════════════
11// Public API: CLI Helpers
12// ═══════════════════════════════════════════
13
14/// Output format for CLI and pipe/MCP modes.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum OutputFormat {
17    Json,
18    /// Structure-preserving YAML (same semantics as [`OutputFormat::Json`]).
19    Yaml,
20    Plain,
21}
22
23/// Parsed and normalized log filters (trimmed, lowercased, deduplicated).
24///
25/// Semantics (a stable contract):
26/// - An **empty** set emits no logs (filtering is opt-in, not opt-out).
27/// - The single wildcard word `"all"` emits every log. (`"*"` is not special —
28///   there is one wildcard spelling, not two.)
29/// - Otherwise a log is emitted iff its lowercased event name **starts with**
30///   any filter string (prefix match).
31///
32/// Consequence to know: a mistyped filter simply matches nothing, so it
33/// silently emits no output — that is the documented behavior, not a bug.
34#[derive(Clone, Debug, Default, PartialEq, Eq)]
35pub struct LogFilters(Vec<String>);
36
37impl LogFilters {
38    /// Create a new LogFilters from filter strings. Entries are trimmed,
39    /// lowercased, and de-duplicated; empty entries are dropped.
40    pub fn new<I, S>(filters: I) -> Self
41    where
42        I: IntoIterator<Item = S>,
43        S: AsRef<str>,
44    {
45        let mut out: Vec<String> = Vec::new();
46        for entry in filters {
47            let s = entry.as_ref().trim().to_ascii_lowercase();
48            if !s.is_empty() && !out.contains(&s) {
49                out.push(s);
50            }
51        }
52        Self(out)
53    }
54
55    /// Check if an event should be logged based on these filters.
56    ///
57    /// Returns `false` if empty (no logs). Returns `true` if the set contains
58    /// the wildcard word `"all"`. Otherwise returns `true` iff the lowercased
59    /// event name starts with any filter (prefix match).
60    pub fn enabled(&self, event: &str) -> bool {
61        if self.0.is_empty() {
62            return false;
63        }
64        let event_lower = event.to_ascii_lowercase();
65        if self.0.contains(&"all".to_string()) {
66            return true;
67        }
68        self.0.iter().any(|filter| event_lower.starts_with(filter))
69    }
70
71    /// Check if this filter set is empty (no filters configured).
72    pub fn is_empty(&self) -> bool {
73        self.0.is_empty()
74    }
75
76    /// Access the underlying filter strings as a slice.
77    pub fn as_slice(&self) -> &[String] {
78        &self.0
79    }
80}
81
82/// Parse `--output` flag value into [`OutputFormat`].
83///
84/// Returns `Err` with a message suitable for passing to [`build_cli_error`] on unknown values.
85///
86/// ```
87/// use agent_first_data::{cli_parse_output, OutputFormat};
88/// assert!(matches!(cli_parse_output("json"), Ok(OutputFormat::Json)));
89/// assert!(cli_parse_output("xml").is_err());
90/// ```
91pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
92    match s {
93        "json" => Ok(OutputFormat::Json),
94        "yaml" => Ok(OutputFormat::Yaml),
95        "plain" => Ok(OutputFormat::Plain),
96        _ => Err(format!(
97            "invalid --output format '{s}': expected json, yaml, or plain"
98        )),
99    }
100}
101
102/// Normalize `--log` flag entries: trim, lowercase, deduplicate, remove empty.
103///
104/// Accepts pre-split entries as produced by clap's `value_delimiter = ','`.
105///
106/// ```
107/// use agent_first_data::{cli_parse_log_filters, LogFilters};
108/// let f = cli_parse_log_filters(&["Query", " error ", "query"]);
109/// assert_eq!(f, LogFilters::new(["query", "error"]));
110/// ```
111pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> LogFilters {
112    LogFilters::new(entries.iter().map(AsRef::as_ref))
113}
114
115/// Error returned by [`CliEmitter`].
116#[derive(Debug)]
117pub enum CliEmitterError {
118    /// A protocol-validation failure.
119    Validation(ProtocolViolation),
120    /// An event builder rejected its inputs (empty code/message, reserved field).
121    Build(BuildError),
122    /// An emitter lifecycle rule was violated (terminal ordering).
123    Lifecycle(String),
124    /// Writing the event to the underlying writer failed.
125    Write(std::io::Error),
126}
127
128impl std::fmt::Display for CliEmitterError {
129    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130        match self {
131            Self::Validation(v) => write!(f, "{v}"),
132            Self::Build(e) => write!(f, "{e}"),
133            Self::Lifecycle(err) => f.write_str(err),
134            Self::Write(err) => write!(f, "failed to write CLI event: {err}"),
135        }
136    }
137}
138
139impl CliEmitterError {
140    /// Return the underlying writer error, when event emission failed during I/O.
141    pub const fn io_error(&self) -> Option<&std::io::Error> {
142        match self {
143            Self::Write(err) => Some(err),
144            Self::Validation(_) | Self::Build(_) | Self::Lifecycle(_) => None,
145        }
146    }
147
148    /// Return the underlying writer error kind, when available.
149    pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
150        self.io_error().map(std::io::Error::kind)
151    }
152}
153
154impl std::error::Error for CliEmitterError {
155    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
156        self.io_error()
157            .map(|err| err as &(dyn std::error::Error + 'static))
158    }
159}
160
161impl From<std::io::Error> for CliEmitterError {
162    fn from(err: std::io::Error) -> Self {
163        Self::Write(err)
164    }
165}
166
167/// Where a [`CliEmitter`] sends its events, selected by `--output-to`.
168///
169/// The stream an event lands on follows the program's *consumption mode*, not
170/// the event's shape (see the spec's CLI Event Framing):
171///
172/// - [`OutputTo::Split`] (the default) is finite one-shot mode: `result` goes
173///   to `stdout`, while `error`/`progress`/`log` go to `stderr`. `stdout`
174///   therefore carries only successful payloads, so a shell capture or pipe
175///   never mistakes a failure for data.
176/// - [`OutputTo::Stdout`] / [`OutputTo::Stderr`] are event-stream mode: every
177///   event, including `error`, is collapsed onto that one stream so a consumer
178///   reading it in order (`kind`-branching) sees preserved ordering.
179///
180/// A command is an event stream when it produces more than one caller-needed
181/// output over time — a chunked payload, or an address the caller must act on
182/// before the command can report its outcome. Such a command defaults to
183/// [`OutputTo::Stdout`] and rejects an explicit `split`, because splitting it
184/// would strand the caller's data on the diagnostic stream. If a
185/// `kind:"progress"` event carries a payload the caller must read, the command
186/// is an event stream that has not declared itself.
187#[derive(Clone, Copy, Debug, PartialEq, Eq)]
188pub enum OutputTo {
189    /// Finite one-shot: `result` → stdout, `error`/`progress`/`log` → stderr.
190    Split,
191    /// Event stream: every event onto stdout.
192    Stdout,
193    /// Event stream: every event onto stderr.
194    Stderr,
195}
196
197impl OutputTo {
198    /// Parse an `--output-to` value: `split` (default), `stdout`, or `stderr`.
199    pub fn parse(value: &str) -> Result<Self, String> {
200        match value {
201            "split" => Ok(Self::Split),
202            "stdout" => Ok(Self::Stdout),
203            "stderr" => Ok(Self::Stderr),
204            other => Err(format!(
205                "unsupported --output-to `{other}`; expected split, stdout, or stderr"
206            )),
207        }
208    }
209}
210
211/// Stateful emitter for structured CLI executions.
212///
213/// The output format, redaction policy, and stream routing are fixed when the
214/// emitter is created. Emitting after a terminal event, emitting a repeated
215/// terminal event, and writer failures all return explicit errors.
216///
217/// Routing follows the consumption mode ([`OutputTo`]):
218///
219/// - [`CliEmitter::finite`] / [`CliEmitter::finite_with`] — finite one-shot:
220///   `result` → the primary writer (stdout), `error`/`progress`/`log` → the
221///   diagnostic writer (stderr). This is the recommended default for a
222///   one-shot CLI, so shell capture and pipelines never treat a failure as data.
223/// - [`CliEmitter::stream`] — event stream: every event, including `error`,
224///   goes to the single writer, preserving interleaved ordering.
225/// - [`CliEmitter::from_output_to`] builds either shape from a parsed
226///   [`OutputTo`] selector.
227pub struct CliEmitter<W: std::io::Write> {
228    writer: W,
229    diagnostic: Option<Box<dyn std::io::Write>>,
230    format: OutputFormat,
231    output_options: OutputOptions,
232    strict_protocol: bool,
233    terminal_emitted: bool,
234    log_fields_provider: Option<Box<dyn Fn() -> Value>>,
235}
236
237impl<W: std::io::Write> CliEmitter<W> {
238    /// Create an event-stream emitter: every event goes to `writer`.
239    ///
240    /// Alias for [`CliEmitter::stream`]. Use [`CliEmitter::finite`] for a
241    /// one-shot command that should split `result`/`error` across stdout/stderr.
242    pub fn new(writer: W, format: OutputFormat) -> Self {
243        Self::stream(writer, format)
244    }
245
246    /// Create an event-stream emitter with custom output options.
247    pub fn with_options(writer: W, format: OutputFormat, output_options: OutputOptions) -> Self {
248        Self {
249            writer,
250            diagnostic: None,
251            format,
252            output_options,
253            strict_protocol: false,
254            terminal_emitted: false,
255            log_fields_provider: None,
256        }
257    }
258
259    /// Create an event-stream emitter: every event, including `error`, goes to
260    /// the single `writer`, preserving interleaved ordering. Pick this when the
261    /// consumer reads one ordered stream and branches on `kind`.
262    pub fn stream(writer: W, format: OutputFormat) -> Self {
263        Self::with_options(writer, format, OutputOptions::default())
264    }
265
266    /// Create a finite one-shot emitter with explicit sinks: `result` goes to
267    /// `result_writer`, while `error`/`progress`/`log` go to `diagnostic`.
268    pub fn finite_with(
269        result_writer: W,
270        diagnostic: impl std::io::Write + 'static,
271        format: OutputFormat,
272    ) -> Self {
273        Self::finite_with_options(result_writer, diagnostic, format, OutputOptions::default())
274    }
275
276    /// Create a finite one-shot emitter with explicit sinks and output options.
277    pub fn finite_with_options(
278        result_writer: W,
279        diagnostic: impl std::io::Write + 'static,
280        format: OutputFormat,
281        output_options: OutputOptions,
282    ) -> Self {
283        Self {
284            writer: result_writer,
285            diagnostic: Some(Box::new(diagnostic)),
286            format,
287            output_options,
288            strict_protocol: false,
289            terminal_emitted: false,
290            log_fields_provider: None,
291        }
292    }
293
294    /// Require the AFDATA recommended strict profile for every emitted event.
295    pub fn with_strict_protocol(mut self) -> Self {
296        self.strict_protocol = true;
297        self
298    }
299
300    /// Set a provider for default log fields.
301    ///
302    /// The provider is called for every log event (via emit_log or emit with kind:log).
303    /// Its output is merged as extension fields; explicit call-site fields take precedence.
304    pub fn with_log_fields<F>(mut self, provider: F) -> Self
305    where
306        F: Fn() -> Value + 'static,
307    {
308        self.log_fields_provider = Some(Box::new(provider));
309        self
310    }
311
312    /// Emit a typed Event (unified entry for all event kinds).
313    ///
314    /// Accepts only SDK-constructed Event; for dynamic JSON, use emit_validated_value.
315    pub fn emit(&mut self, event: Event) -> Result<(), CliEmitterError> {
316        let value = event.into_value();
317        self.write_event(value)
318    }
319
320    /// Emit and validate dynamic JSON, then apply redaction/formatting/write.
321    ///
322    /// Runs strict validation first, ensuring the dynamic JSON is safe.
323    pub fn emit_validated_value(&mut self, value: Value) -> Result<(), CliEmitterError> {
324        validate_protocol_event(&value, true).map_err(CliEmitterError::Validation)?;
325        self.write_event(value)
326    }
327
328    /// Convenience: build and emit a result event.
329    pub fn emit_result(&mut self, payload: Value) -> Result<(), CliEmitterError> {
330        self.emit(json_result(payload).build())
331    }
332
333    /// Convenience: build and emit an error event.
334    pub fn emit_error(&mut self, code: &str, message: &str) -> Result<(), CliEmitterError> {
335        match json_error(code, message).build() {
336            Ok(event) => self.emit(event),
337            Err(err) => Err(CliEmitterError::Build(err)),
338        }
339    }
340
341    /// Convenience: build and emit a progress event.
342    pub fn emit_progress(&mut self, message: &str) -> Result<(), CliEmitterError> {
343        self.emit(json_progress(serde_json::json!({ "message": message })).build())
344    }
345
346    /// Convenience: build and emit a log event with default fields.
347    ///
348    /// Applies log_fields_provider if configured; explicit fields take precedence.
349    pub fn emit_log(&mut self, level: LogLevel, message: &str) -> Result<(), CliEmitterError> {
350        let mut event = json_log(serde_json::json!({
351            "level": level.as_str(),
352            "message": message,
353        }))
354        .build()
355        .into_value();
356        if let Some(provider) = &self.log_fields_provider {
357            let provider_fields = provider();
358            if let Some(log_obj) = event.get_mut("log").and_then(|v| v.as_object_mut())
359                && let Value::Object(fields) = provider_fields
360            {
361                for (k, v) in fields {
362                    log_obj.entry(k).or_insert(v);
363                }
364            }
365        }
366        self.write_event(event)
367    }
368
369    /// Emit `event` as the terminal event and resolve the outcome to a process
370    /// exit code, so a one-shot CLI need not hand-roll the emit-then-exit dance.
371    ///
372    /// A successful write returns `success_code`; a broken pipe (the reader hung
373    /// up) returns `0`; any other write or validation failure returns `4`. A
374    /// library never calls `process::exit` itself — return this code from `main`
375    /// (`std::process::ExitCode::from(code)`).
376    pub fn finish(&mut self, event: Event, success_code: u8) -> u8 {
377        match self.emit(event) {
378            Ok(()) => success_code,
379            Err(err) if err.io_error_kind() == Some(std::io::ErrorKind::BrokenPipe) => 0,
380            Err(_) => 4,
381        }
382    }
383
384    /// Convenience over [`CliEmitter::finish`]: emit a `result` payload and
385    /// return `0` on success.
386    ///
387    /// For an error, build it with [`json_error`] (`.hint(…)`, `.retryable(…)`,
388    /// `.field(…)` as needed) and pass the event to [`CliEmitter::finish`] with
389    /// the desired exit code — the builder is the error type, so no separate
390    /// error-emitting convenience is needed.
391    pub fn finish_result(&mut self, payload: Value) -> u8 {
392        self.finish(json_result(payload).build(), 0)
393    }
394
395    /// Access the underlying writer.
396    pub fn into_inner(self) -> W {
397        self.writer
398    }
399
400    fn write_event(&mut self, event: Value) -> Result<(), CliEmitterError> {
401        validate_protocol_event(&event, self.strict_protocol)
402            .map_err(CliEmitterError::Validation)?;
403        let kind = event.get("kind").and_then(Value::as_str).ok_or_else(|| {
404            CliEmitterError::Validation(ProtocolViolation {
405                rule: "kind_invalid",
406                pointer: "/kind".to_string(),
407                message: "event.kind is required".to_string(),
408            })
409        })?;
410        match kind {
411            "log" | "progress" => {
412                if self.terminal_emitted {
413                    return Err(CliEmitterError::Lifecycle(
414                        "cannot emit non-terminal event after terminal event".to_string(),
415                    ));
416                }
417            }
418            "result" | "error" => {
419                if self.terminal_emitted {
420                    return Err(CliEmitterError::Lifecycle(
421                        "cannot emit duplicate terminal event".to_string(),
422                    ));
423                }
424            }
425            _ => {
426                return Err(CliEmitterError::Validation(ProtocolViolation {
427                    rule: "kind_unsupported",
428                    pointer: "/kind".to_string(),
429                    message: format!("unsupported event kind {kind:?}"),
430                }));
431            }
432        }
433        let rendered = crate::formatting::render(&event, self.format, &self.output_options);
434        // Finite mode (a diagnostic sink is present) splits by kind: `result`
435        // stays on the primary writer (stdout), while `error`/`progress`/`log`
436        // are diagnostics routed to the diagnostic writer (stderr). Event-stream
437        // mode (no diagnostic sink) keeps every event on the single writer.
438        match &mut self.diagnostic {
439            Some(diagnostic) if kind != "result" => {
440                write_event_line(diagnostic.as_mut(), &rendered)
441            }
442            _ => write_event_line(&mut self.writer, &rendered),
443        }?;
444        if matches!(kind, "result" | "error") {
445            self.terminal_emitted = true;
446        }
447        Ok(())
448    }
449}
450
451/// Write one rendered event line (payload plus trailing newline) and flush.
452fn write_event_line(writer: &mut dyn std::io::Write, rendered: &str) -> std::io::Result<()> {
453    writer.write_all(rendered.as_bytes())?;
454    writer.write_all(b"\n")?;
455    writer.flush()
456}
457
458// The emitter's own diagnostic sink is the spec's sanctioned exception to the
459// "no ad-hoc stderr" rule (Channel policy): a finite one-shot emitter routes
460// `error`/`progress`/`log` to `std::io::stderr` on purpose, so these wired
461// constructors are allowed to name it directly.
462#[allow(clippy::disallowed_methods)]
463impl CliEmitter<std::io::Stdout> {
464    /// Create a finite one-shot emitter wired to the process streams: `result`
465    /// → `stdout`, `error`/`progress`/`log` → `stderr`. The recommended default
466    /// for a one-shot CLI.
467    pub fn finite(format: OutputFormat) -> Self {
468        Self::finite_with(std::io::stdout(), std::io::stderr(), format)
469    }
470
471    /// Create a finite one-shot emitter wired to the process streams, with
472    /// custom output options.
473    pub fn finite_options(format: OutputFormat, output_options: OutputOptions) -> Self {
474        Self::finite_with_options(std::io::stdout(), std::io::stderr(), format, output_options)
475    }
476}
477
478// Same sanctioned exception as above: `from_output_to` wires the process
479// streams (`std::io::stderr` included) as the emitter's own sinks.
480#[allow(clippy::disallowed_methods)]
481impl CliEmitter<Box<dyn std::io::Write>> {
482    /// Build an emitter from a parsed [`OutputTo`] selector, wired to the
483    /// process streams: `Split` is finite mode (`result` → stdout, everything
484    /// else → stderr); `Stdout`/`Stderr` are event-stream mode onto that stream.
485    pub fn from_output_to(selector: OutputTo, format: OutputFormat) -> Self {
486        Self::from_output_to_with(selector, format, OutputOptions::default())
487    }
488
489    /// As [`CliEmitter::from_output_to`], with custom output options.
490    pub fn from_output_to_with(
491        selector: OutputTo,
492        format: OutputFormat,
493        output_options: OutputOptions,
494    ) -> Self {
495        match selector {
496            OutputTo::Split => Self::finite_with_options(
497                Box::new(std::io::stdout()),
498                std::io::stderr(),
499                format,
500                output_options,
501            ),
502            OutputTo::Stdout => {
503                Self::with_options(Box::new(std::io::stdout()), format, output_options)
504            }
505            OutputTo::Stderr => {
506                Self::with_options(Box::new(std::io::stderr()), format, output_options)
507            }
508        }
509    }
510}
511
512/// Build a standard CLI version event: a `kind:"result"` event whose payload is
513/// `{ "code": "version", "name": <name>, "version": <version> }`, plus
514/// `"display_name"`/`"build"` when given. `name` is the short/bin identity
515/// (e.g. `"afdata"`); `display_name` is an optional human-facing product name
516/// (e.g. `"Agent-First Data"`); `build` is an opaque caller-supplied identifier (a git
517/// commit SHA, for example) — its meaning is entirely up to the caller. Both
518/// are `None` when unavailable, and simply absent from the payload.
519pub fn build_cli_version(
520    name: &str,
521    display_name: Option<&str>,
522    version: &str,
523    build: Option<&str>,
524) -> Event {
525    let mut payload = serde_json::json!({
526        "code": "version",
527        "name": name,
528        "version": version,
529    });
530    if let Some(display_name) = display_name {
531        payload["display_name"] = Value::String(display_name.to_string());
532    }
533    if let Some(build) = build {
534        payload["build"] = Value::String(build.to_string());
535    }
536    json_result(payload).build()
537}
538
539/// Render a CLI version response as a protocol-v1 event in `format`.
540pub fn cli_render_version(
541    name: &str,
542    display_name: Option<&str>,
543    version: &str,
544    build: Option<&str>,
545    format: OutputFormat,
546) -> String {
547    let mut rendered = crate::formatting::render(
548        build_cli_version(name, display_name, version, build).as_value(),
549        format,
550        &OutputOptions::default(),
551    );
552    while rendered.ends_with('\n') {
553        rendered.pop();
554    }
555    rendered.push('\n');
556    rendered
557}
558
559/// Render version output from raw argv if `--version` or `-V` is present.
560///
561/// `raw_args` should be the full argv vector, including argv[0], as produced by
562/// `std::env::args()`. The helper intentionally runs before clap or another
563/// parser so explicit `--output json|yaml|plain` is honored instead of being
564/// bypassed by built-in version handling. `cmd` is the caller's own
565/// `clap::Command` (typically `Cli::command()`) — used to inherit the declared
566/// `--output` default and to look up which flags take a value, so any global
567/// flag the caller defines (`--stdout-file`, or one added later) is recognized
568/// without the pre-parser having to hardcode its name.
569///
570/// Only a *top-level* version request is recognized: scanning stops at the first
571/// positional argument (the subcommand), so `tool sub --version <value>` leaves
572/// `--version` for the subcommand's parser rather than printing the tool version.
573/// That boundary check is unconditional and runs before any flag is inspected —
574/// it is unaffected by `cmd`, which only decides how many argv slots a
575/// *recognized flag* consumes, so a flag's value is never mistaken for it.
576///
577/// The one blessed behavior: `--version` always answers with a protocol-v1
578/// `kind:"result"` version event (payload `{ "code": "version", "name", ...
579/// }`, see [`build_cli_version`]). An explicit `--output` wins;
580/// otherwise the handler inherits the command's declared `--output` default,
581/// falling back to JSON. Returns a standard [`build_cli_error`] event when the
582/// request is malformed, for example `--version --output xml`.
583#[cfg(any(feature = "cli", feature = "cli-help"))]
584pub fn cli_handle_version_or_continue(
585    raw_args: &[String],
586    cmd: &clap::Command,
587    name: &str,
588    display_name: Option<&str>,
589    version: &str,
590    build: Option<&str>,
591) -> Result<Option<String>, Event> {
592    let parsed = parse_version_request(raw_args, cmd);
593    if !parsed.version_requested {
594        return Ok(None);
595    }
596    if let Some(error) = parsed.output_error {
597        let event = build_cli_error(
598            &error,
599            Some("valid version output formats: json, yaml, plain"),
600        );
601        return Err(event);
602    }
603    Ok(Some(cli_render_version(
604        name,
605        display_name,
606        version,
607        build,
608        parsed
609            .output_format
610            .or_else(|| command_output_default(cmd))
611            .unwrap_or(OutputFormat::Json),
612    )))
613}
614
615#[cfg(any(feature = "cli", feature = "cli-help"))]
616fn command_output_default(cmd: &clap::Command) -> Option<OutputFormat> {
617    cmd.get_arguments()
618        .find(|arg| arg.get_long() == Some("output"))
619        .and_then(|arg| arg.get_default_values().first())
620        .and_then(|value| value.to_str())
621        .and_then(|value| cli_parse_output(value).ok())
622}
623
624#[cfg(any(feature = "cli", feature = "cli-help"))]
625struct ParsedVersionRequest {
626    version_requested: bool,
627    output_format: Option<OutputFormat>,
628    output_error: Option<String>,
629}
630
631/// Drop argv[0] from a raw argument vector.
632///
633/// `raw_args` is documented as the full argv, so argv[0] is the program path and
634/// never an argument. The one concession is a caller that passes bare arguments:
635/// argv[0] can never start with `-`, so a leading `-` means the vector is
636/// already stripped. Matching argv[0] against subcommand names would be a
637/// second, unsafe concession — a binary legitimately named after one of its own
638/// subcommands would have its program path parsed as that subcommand.
639#[cfg(any(feature = "cli", feature = "cli-help"))]
640pub(crate) fn strip_argv0(raw_args: &[String]) -> &[String] {
641    match raw_args.first() {
642        Some(first) if first.starts_with('-') => raw_args,
643        _ => raw_args.get(1..).unwrap_or(&[]),
644    }
645}
646
647#[cfg(any(feature = "cli", feature = "cli-help"))]
648fn parse_version_request(raw_args: &[String], cmd: &clap::Command) -> ParsedVersionRequest {
649    let args = strip_argv0(raw_args);
650    let mut version_requested = false;
651    let mut output_format = None;
652    let mut output_error = None;
653
654    let mut i = 0usize;
655    while i < args.len() {
656        let arg = args[i].as_str();
657        if arg == "--" {
658            break;
659        }
660        // The first positional argument marks the subcommand boundary. Past it,
661        // `--version` (and `-V`) belong to the subcommand's own parser, matching
662        // git/cargo/clap: the pre-parser only owns a top-level version request.
663        if !arg.starts_with('-') {
664            break;
665        }
666
667        let (flag_name, inline_value) = split_flag(arg);
668        if arg == "--version" {
669            version_requested = true;
670            i += 1;
671            continue;
672        }
673
674        // `--output-to` takes a value but does not affect version text output.
675        // Consume its space-separated value so it is not mistaken for the
676        // subcommand boundary (which would hide a later `--version`/`--output`).
677        if flag_name == Some("output-to") {
678            let has_space_value = inline_value.is_none()
679                && args
680                    .get(i + 1)
681                    .map(|next| !next.starts_with('-'))
682                    .unwrap_or(false);
683            i += if has_space_value { 2 } else { 1 };
684            continue;
685        }
686
687        if flag_name == Some("output") {
688            let value = inline_value.or_else(|| {
689                args.get(i + 1)
690                    .map(String::as_str)
691                    .filter(|next| !next.starts_with('-'))
692            });
693            if let Some(value) = value {
694                match cli_parse_output(value) {
695                    Ok(format) => set_version_output_format(
696                        &mut output_format,
697                        format,
698                        &format!("--output {value}"),
699                        &mut output_error,
700                    ),
701                    Err(err) => output_error = Some(err),
702                }
703            } else {
704                output_error =
705                    Some("missing value for --output: expected json, yaml, or plain".to_string());
706            }
707            i += if inline_value.is_some() || value.is_none() {
708                1
709            } else {
710                2
711            };
712            continue;
713        }
714
715        // Any other flag: ask the caller's real Command whether it takes a
716        // value (covers `--stdout-file`/`--stderr-file` and any other global
717        // flag the caller defines) so its value is never mistaken for the
718        // subcommand boundary above.
719        let has_space_value = inline_value.is_none()
720            && args
721                .get(i + 1)
722                .map(|next| !next.starts_with('-'))
723                .unwrap_or(false);
724        i += if has_space_value && flag_takes_value(cmd, arg) {
725            2
726        } else {
727            1
728        };
729    }
730
731    ParsedVersionRequest {
732        version_requested,
733        output_format,
734        output_error,
735    }
736}
737
738#[cfg(any(feature = "cli", feature = "cli-help"))]
739fn set_version_output_format(
740    current: &mut Option<OutputFormat>,
741    next: OutputFormat,
742    source: &str,
743    output_error: &mut Option<String>,
744) {
745    if let Some(existing) = current
746        && *existing != next
747    {
748        *output_error = Some(format!(
749            "conflicting output formats: {source} conflicts with previous output format"
750        ));
751        return;
752    }
753    *current = Some(next);
754}
755
756#[cfg(any(feature = "cli", feature = "cli-help"))]
757fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
758    if !arg.starts_with('-') || arg == "-" {
759        return (None, None);
760    }
761    let (flag, value) = arg.split_once('=').unwrap_or((arg, ""));
762    let name = flag.trim_start_matches('-');
763    if name.is_empty() {
764        (None, None)
765    } else if arg.contains('=') {
766        (Some(name), Some(value))
767    } else {
768        (Some(name), None)
769    }
770}
771
772// A local copy, not a shared import from `help` (gated behind the stricter
773// `cli-help` alone): this parser only requires the more basic `cli` feature,
774// mirroring how `split_flag` above is already duplicated rather than shared.
775#[cfg(any(feature = "cli", feature = "cli-help"))]
776fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
777    let Some(flag) = raw_flag.strip_prefix('-') else {
778        return false;
779    };
780    let name = flag.trim_start_matches('-');
781    cmd.get_arguments().any(|arg| {
782        let long_matches = arg.get_long().is_some_and(|long| long == name);
783        let short_matches =
784            name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
785        (long_matches || short_matches)
786            && matches!(
787                arg.get_action(),
788                clap::ArgAction::Set | clap::ArgAction::Append
789            )
790    })
791}