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}