Skip to main content

agent_first_data/
cli.rs

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