Skip to main content

agent_first_data/
cli.rs

1use crate::formatting::{
2    output_json, output_json_with_options, output_plain, output_plain_with_options, output_yaml,
3    output_yaml_with_options,
4};
5use crate::protocol::{
6    Event, LogLevel, json_error, json_log, json_progress, json_result, validate_protocol_event,
7};
8use crate::redaction::OutputOptions;
9use serde_json::Value;
10
11// ═══════════════════════════════════════════
12// Public API: CLI Helpers
13// ═══════════════════════════════════════════
14
15/// Output format for CLI and pipe/MCP modes.
16#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum OutputFormat {
18    Json,
19    Yaml,
20    Plain,
21}
22
23/// Parsed and normalized log filters (trimmed, lowercased, deduplicated).
24///
25/// Filters enable selective tracing output via `--log` flags. An empty set means
26/// no filtering (no logs emitted). The set "all" or "*" means all logs emitted.
27/// Otherwise, a log message is emitted iff its event name (lowercased) starts
28/// with any filter string.
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub struct LogFilters(Vec<String>);
31
32impl LogFilters {
33    /// Create a new LogFilters from filter strings. Entries are trimmed,
34    /// lowercased, and de-duplicated; empty entries are dropped.
35    pub fn new<I, S>(filters: I) -> Self
36    where
37        I: IntoIterator<Item = S>,
38        S: AsRef<str>,
39    {
40        let mut out: Vec<String> = Vec::new();
41        for entry in filters {
42            let s = entry.as_ref().trim().to_ascii_lowercase();
43            if !s.is_empty() && !out.contains(&s) {
44                out.push(s);
45            }
46        }
47        Self(out)
48    }
49
50    /// Check if an event should be logged based on these filters.
51    ///
52    /// Returns `false` if empty (no logs). Returns `true` if contains "all" or "*".
53    /// Otherwise returns `true` iff the lowercased event name starts with any filter.
54    pub fn enabled(&self, event: &str) -> bool {
55        if self.0.is_empty() {
56            return false;
57        }
58        let event_lower = event.to_ascii_lowercase();
59        if self.0.contains(&"all".to_string()) || self.0.contains(&"*".to_string()) {
60            return true;
61        }
62        self.0.iter().any(|filter| event_lower.starts_with(filter))
63    }
64
65    /// Check if this filter set is empty (no filters configured).
66    pub fn is_empty(&self) -> bool {
67        self.0.is_empty()
68    }
69
70    /// Access the underlying filter strings as a slice.
71    pub fn as_slice(&self) -> &[String] {
72        &self.0
73    }
74}
75
76/// Protocol envelope behavior for early CLI exits such as `--version`.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum CliProtocolMode {
79    /// Preserve the historical structured payload shape.
80    Legacy,
81    /// Emit a strict AFDATA protocol-v1 event with an empty trace object.
82    ProtocolV1,
83}
84
85/// Configuration for pre-parser `--version` handling.
86///
87/// This helper scans raw argv before the application's argument parser so
88/// `--version --output json` can return an AFDATA event instead of letting
89/// clap or another parser print conventional plain text and exit.
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct VersionConfig {
92    /// Format used for `--version` when no explicit output flag is present.
93    ///
94    /// `Some(format)` renders an AFDATA `kind:"result"` version event in that
95    /// format. `None` preserves conventional CLI output: `<name> <version>`.
96    pub default_output: Option<OutputFormat>,
97    /// Optional long output flag to read, for example `--output`.
98    pub output_flag: Option<&'static str>,
99    /// Optional short output flag to read, for example `-o`.
100    pub output_short: Option<char>,
101    /// Whether an explicit output flag can override `default_output`.
102    pub allow_output_format: bool,
103    /// Envelope mode for structured version output and early errors.
104    pub protocol_mode: CliProtocolMode,
105}
106
107impl VersionConfig {
108    /// Construct a custom version handler configuration.
109    pub const fn new(default_output: Option<OutputFormat>) -> Self {
110        Self {
111            default_output,
112            output_flag: None,
113            output_short: None,
114            allow_output_format: false,
115            protocol_mode: CliProtocolMode::Legacy,
116        }
117    }
118
119    /// Structured bare-version preset.
120    ///
121    /// A bare `--version` is a JSON AFDATA event. Most CLIs should prefer
122    /// [`Self::conventional_default`] so human `--version` stays familiar while
123    /// explicit `--output json|yaml|plain` remains structured.
124    pub const fn agent_cli_default() -> Self {
125        Self {
126            default_output: Some(OutputFormat::Json),
127            output_flag: Some("--output"),
128            output_short: None,
129            allow_output_format: true,
130            protocol_mode: CliProtocolMode::Legacy,
131        }
132    }
133
134    /// Recommended preset: keep conventional bare version text while still
135    /// honoring explicit `--output json|yaml|plain`.
136    pub const fn conventional_default() -> Self {
137        Self {
138            default_output: None,
139            output_flag: Some("--output"),
140            output_short: None,
141            allow_output_format: true,
142            protocol_mode: CliProtocolMode::Legacy,
143        }
144    }
145
146    /// Return a copy with a different default output.
147    pub const fn with_default_output(mut self, default_output: Option<OutputFormat>) -> Self {
148        self.default_output = default_output;
149        self
150    }
151
152    /// Return a copy with a different long output flag.
153    pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
154        self.output_flag = flag;
155        self
156    }
157
158    /// Return a copy with a different short output flag.
159    pub const fn with_output_short(mut self, flag: Option<char>) -> Self {
160        self.output_short = flag;
161        self
162    }
163
164    /// Return a copy that enables or disables explicit output overrides.
165    pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
166        self.allow_output_format = enabled;
167        self
168    }
169
170    /// Return a copy that emits protocol-v1 structured early exits.
171    pub const fn with_protocol_v1(mut self) -> Self {
172        self.protocol_mode = CliProtocolMode::ProtocolV1;
173        self
174    }
175}
176
177/// Parse `--output` flag value into [`OutputFormat`].
178///
179/// Returns `Err` with a message suitable for passing to [`build_cli_error`] on unknown values.
180///
181/// ```
182/// use agent_first_data::{cli_parse_output, OutputFormat};
183/// assert!(matches!(cli_parse_output("json"), Ok(OutputFormat::Json)));
184/// assert!(cli_parse_output("xml").is_err());
185/// ```
186pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
187    match s {
188        "json" => Ok(OutputFormat::Json),
189        "yaml" => Ok(OutputFormat::Yaml),
190        "plain" => Ok(OutputFormat::Plain),
191        _ => Err(format!(
192            "invalid --output format '{s}': expected json, yaml, or plain"
193        )),
194    }
195}
196
197/// Normalize `--log` flag entries: trim, lowercase, deduplicate, remove empty.
198///
199/// Accepts pre-split entries as produced by clap's `value_delimiter = ','`.
200///
201/// ```
202/// use agent_first_data::{cli_parse_log_filters, LogFilters};
203/// let f = cli_parse_log_filters(&["Query", " error ", "query"]);
204/// assert_eq!(f, LogFilters::new(["query", "error"]));
205/// ```
206pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> LogFilters {
207    LogFilters::new(entries.iter().map(AsRef::as_ref))
208}
209
210/// Dispatch output formatting by [`OutputFormat`].
211///
212/// Equivalent to calling [`output_json`], [`output_yaml`], or [`output_plain`] directly.
213///
214/// ```
215/// use agent_first_data::{cli_output, json_result, OutputFormat};
216/// let v = json_result(serde_json::json!({"ok": true})).build().expect("valid afdata event");
217/// let s = cli_output(v.as_value(), OutputFormat::Plain);
218/// assert!(s.contains("kind=result"));
219/// ```
220pub fn cli_output(value: &Value, format: OutputFormat) -> String {
221    match format {
222        OutputFormat::Json => output_json(value),
223        OutputFormat::Yaml => output_yaml(value),
224        OutputFormat::Plain => output_plain(value),
225    }
226}
227
228/// Dispatch output formatting by [`OutputFormat`] with configurable output options.
229///
230/// JSON output ignores [`OutputStyle`] and always preserves original keys and values after
231/// redaction. YAML and plain output use the requested style.
232pub fn cli_output_with_options(
233    value: &Value,
234    format: OutputFormat,
235    output_options: &OutputOptions,
236) -> String {
237    match format {
238        OutputFormat::Json => output_json_with_options(value, output_options),
239        OutputFormat::Yaml => output_yaml_with_options(value, output_options),
240        OutputFormat::Plain => output_plain_with_options(value, output_options),
241    }
242}
243
244/// Error returned by [`CliEmitter`].
245#[derive(Debug)]
246pub enum CliEmitterError {
247    Validation(String),
248    Lifecycle(String),
249    Write(std::io::Error),
250}
251
252impl std::fmt::Display for CliEmitterError {
253    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254        match self {
255            Self::Validation(err) | Self::Lifecycle(err) => f.write_str(err),
256            Self::Write(err) => write!(f, "failed to write CLI event: {err}"),
257        }
258    }
259}
260
261impl CliEmitterError {
262    /// Return the underlying writer error, when event emission failed during I/O.
263    pub const fn io_error(&self) -> Option<&std::io::Error> {
264        match self {
265            Self::Write(err) => Some(err),
266            Self::Validation(_) | Self::Lifecycle(_) => None,
267        }
268    }
269
270    /// Return the underlying writer error kind, when available.
271    pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
272        self.io_error().map(std::io::Error::kind)
273    }
274}
275
276impl std::error::Error for CliEmitterError {
277    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
278        self.io_error()
279            .map(|err| err as &(dyn std::error::Error + 'static))
280    }
281}
282
283impl From<std::io::Error> for CliEmitterError {
284    fn from(err: std::io::Error) -> Self {
285        Self::Write(err)
286    }
287}
288
289/// Stateful emitter for finite structured CLI executions.
290///
291/// The output format and redaction policy are fixed when the emitter is
292/// created. Emitting after a terminal event, emitting a repeated terminal
293/// event, and writer failures all return explicit errors.
294///
295/// 0.16 API: Accepts typed Event, provides semantic convenience methods,
296/// and supports per-log default field provider.
297pub struct CliEmitter<W: std::io::Write> {
298    writer: W,
299    format: OutputFormat,
300    output_options: OutputOptions,
301    strict_protocol: bool,
302    terminal_emitted: bool,
303    log_fields_provider: Option<Box<dyn Fn() -> Value>>,
304}
305
306impl<W: std::io::Write> CliEmitter<W> {
307    /// Create a new CLI emitter with default output options.
308    pub fn new(writer: W, format: OutputFormat) -> Self {
309        Self::with_options(writer, format, OutputOptions::default())
310    }
311
312    /// Create a new CLI emitter with custom output options.
313    pub fn with_options(writer: W, format: OutputFormat, output_options: OutputOptions) -> Self {
314        Self {
315            writer,
316            format,
317            output_options,
318            strict_protocol: false,
319            terminal_emitted: false,
320            log_fields_provider: None,
321        }
322    }
323
324    /// Require the AFDATA recommended strict profile for every emitted event.
325    pub fn with_strict_protocol(mut self) -> Self {
326        self.strict_protocol = true;
327        self
328    }
329
330    /// Set a provider for default log fields.
331    ///
332    /// The provider is called for every log event (via emit_log or emit with kind:log).
333    /// Its output is merged as extension fields; explicit call-site fields take precedence.
334    /// The provider must not write reserved fields (message, level); violations return a typed error.
335    pub fn with_log_fields<F>(mut self, provider: F) -> Self
336    where
337        F: Fn() -> Value + 'static,
338    {
339        self.log_fields_provider = Some(Box::new(provider));
340        self
341    }
342
343    /// Emit a typed Event (unified entry for all event kinds).
344    ///
345    /// Accepts only SDK-constructed Event; for dynamic JSON, use emit_validated_value.
346    pub fn emit(&mut self, event: Event) -> Result<(), CliEmitterError> {
347        let value = event.into_value();
348        self.write_event(value)
349    }
350
351    /// Emit and validate dynamic JSON, then apply redaction/formatting/write.
352    ///
353    /// Runs strict validation first, ensuring the dynamic JSON is safe.
354    pub fn emit_validated_value(&mut self, value: Value) -> Result<(), CliEmitterError> {
355        validate_protocol_event(&value, true).map_err(CliEmitterError::Validation)?;
356        self.write_event(value)
357    }
358
359    /// Convenience: build and emit a result event.
360    pub fn emit_result(&mut self, payload: Value) -> Result<(), CliEmitterError> {
361        #[allow(clippy::expect_used)]
362        self.emit(
363            json_result(payload)
364                .build()
365                .expect("json_result: builder failed unexpectedly"),
366        )
367    }
368
369    /// Convenience: build and emit an error event.
370    pub fn emit_error(&mut self, code: &str, message: &str) -> Result<(), CliEmitterError> {
371        #[allow(clippy::expect_used)]
372        self.emit(
373            json_error(code, message)
374                .build()
375                .expect("json_error: builder failed unexpectedly"),
376        )
377    }
378
379    /// Convenience: build and emit a progress event.
380    pub fn emit_progress(&mut self, message: &str) -> Result<(), CliEmitterError> {
381        #[allow(clippy::expect_used)]
382        self.emit(
383            json_progress(message)
384                .build()
385                .expect("json_progress: builder failed unexpectedly"),
386        )
387    }
388
389    /// Convenience: build and emit a log event with default fields.
390    ///
391    /// Applies log_fields_provider if configured; explicit fields take precedence.
392    pub fn emit_log(&mut self, level: LogLevel, message: &str) -> Result<(), CliEmitterError> {
393        #[allow(clippy::expect_used)]
394        let mut event = json_log(level, message)
395            .build()
396            .expect("json_log: builder failed unexpectedly")
397            .into_value();
398        if let Some(provider) = &self.log_fields_provider {
399            let provider_fields = provider();
400            if let Some(log_obj) = event.get_mut("log").and_then(|v| v.as_object_mut())
401                && let Value::Object(fields) = provider_fields
402            {
403                for (k, v) in fields {
404                    if !matches!(k.as_str(), "message" | "level" | "code") {
405                        log_obj.entry(k).or_insert(v);
406                    }
407                }
408            }
409        }
410        self.write_event(event)
411    }
412
413    /// Access the underlying writer.
414    pub fn into_inner(self) -> W {
415        self.writer
416    }
417
418    fn write_event(&mut self, event: Value) -> Result<(), CliEmitterError> {
419        validate_protocol_event(&event, self.strict_protocol)
420            .map_err(CliEmitterError::Validation)?;
421        let kind = event
422            .get("kind")
423            .and_then(Value::as_str)
424            .ok_or_else(|| CliEmitterError::Validation("event.kind is required".to_string()))?;
425        match kind {
426            "log" | "progress" => {
427                if self.terminal_emitted {
428                    return Err(CliEmitterError::Lifecycle(
429                        "cannot emit non-terminal event after terminal event".to_string(),
430                    ));
431                }
432            }
433            "result" | "error" => {
434                if self.terminal_emitted {
435                    return Err(CliEmitterError::Lifecycle(
436                        "cannot emit duplicate terminal event".to_string(),
437                    ));
438                }
439            }
440            _ => {
441                return Err(CliEmitterError::Validation(format!(
442                    "unsupported event kind {kind:?}"
443                )));
444            }
445        }
446        let rendered = cli_output_with_options(&event, self.format, &self.output_options);
447        self.writer.write_all(rendered.as_bytes())?;
448        self.writer.write_all(b"\n")?;
449        self.writer.flush()?;
450        if matches!(kind, "result" | "error") {
451            self.terminal_emitted = true;
452        }
453        Ok(())
454    }
455}
456
457/// Build a standard CLI version event.
458#[allow(clippy::expect_used)]
459pub fn build_cli_version(version: &str) -> Event {
460    json_result(serde_json::json!({ "version": version }))
461        .build()
462        .expect("build_cli_version: builder failed unexpectedly")
463}
464
465fn build_cli_version_with_mode(version: &str, mode: CliProtocolMode) -> Event {
466    match mode {
467        CliProtocolMode::Legacy => build_cli_version(version),
468        CliProtocolMode::ProtocolV1 => {
469            let payload = serde_json::json!({ "code": "version", "version": version });
470            #[allow(clippy::expect_used)]
471            json_result(payload)
472                .trace(serde_json::json!({}))
473                .build()
474                .expect("build_cli_version_with_mode: builder failed unexpectedly")
475        }
476    }
477}
478
479/// Render a CLI version response.
480///
481/// Pass `Some(format)` for an AFDATA event in JSON/YAML/plain. Pass `None` to
482/// preserve conventional `<name> <version>` output.
483pub fn cli_render_version(name: &str, version: &str, format: Option<OutputFormat>) -> String {
484    let mut rendered = match format {
485        Some(format) => cli_output(build_cli_version(version).as_value(), format),
486        None => format!("{name} {version}"),
487    };
488    while rendered.ends_with('\n') {
489        rendered.pop();
490    }
491    rendered.push('\n');
492    rendered
493}
494
495/// Render version output from raw argv if `--version` or `-V` is present.
496///
497/// `raw_args` should be the full argv vector, including argv[0], as produced by
498/// `std::env::args()`. The helper intentionally runs before clap or another
499/// parser so explicit `--output json|yaml|plain` is honored instead of being
500/// bypassed by built-in version handling.
501///
502/// Returns a standard [`build_cli_error`] event when the version request is
503/// malformed, for example `--version --output xml`.
504pub fn cli_handle_version_or_continue(
505    raw_args: &[String],
506    name: &str,
507    version: &str,
508    config: &VersionConfig,
509) -> Result<Option<String>, Event> {
510    let parsed = parse_version_request(raw_args, config);
511    if !parsed.version_requested {
512        return Ok(None);
513    }
514    if let Some(error) = parsed.output_error {
515        #[allow(clippy::expect_used)]
516        let event = json_error("cli_error", &error)
517            .hint_if_some(Some("valid version output formats: json, yaml, plain"))
518            .build()
519            .expect("cli_handle_version_or_continue: builder failed");
520        return Err(event);
521    }
522    let format = if config.allow_output_format {
523        parsed.output_format.or(config.default_output)
524    } else {
525        config.default_output
526    };
527    if config.protocol_mode == CliProtocolMode::Legacy {
528        return Ok(Some(cli_render_version(name, version, format)));
529    }
530    let Some(format) = format else {
531        return Ok(Some(cli_render_version(name, version, None)));
532    };
533    let mut rendered = cli_output(
534        build_cli_version_with_mode(version, config.protocol_mode).as_value(),
535        format,
536    );
537    while rendered.ends_with('\n') {
538        rendered.pop();
539    }
540    rendered.push('\n');
541    Ok(Some(rendered))
542}
543
544struct ParsedVersionRequest {
545    version_requested: bool,
546    output_format: Option<OutputFormat>,
547    output_error: Option<String>,
548}
549
550fn parse_version_request(raw_args: &[String], config: &VersionConfig) -> ParsedVersionRequest {
551    let args = raw_args.get(1..).unwrap_or(&[]);
552    let mut version_requested = false;
553    let mut output_format = None;
554    let mut output_error = None;
555    let output_flag = config.output_flag.map(normalize_long_flag);
556
557    let mut i = 0usize;
558    while i < args.len() {
559        let arg = args[i].as_str();
560        if arg == "--" {
561            break;
562        }
563
564        let (flag_name, inline_value) = split_flag(arg);
565        if matches!(arg, "--version" | "-V") {
566            version_requested = true;
567            i += 1;
568            continue;
569        }
570
571        if config.allow_output_format && arg == "--json" {
572            set_version_output_format(
573                &mut output_format,
574                OutputFormat::Json,
575                "--json",
576                &mut output_error,
577            );
578            i += 1;
579            continue;
580        }
581
582        if config.allow_output_format
583            && version_output_flag_matches(flag_name, output_flag, config.output_short)
584        {
585            let value = inline_value.or_else(|| {
586                args.get(i + 1)
587                    .map(String::as_str)
588                    .filter(|next| !next.starts_with('-'))
589            });
590            if let Some(value) = value {
591                match cli_parse_output(value) {
592                    Ok(format) => set_version_output_format(
593                        &mut output_format,
594                        format,
595                        &format!("--{} {value}", output_flag.unwrap_or("output")),
596                        &mut output_error,
597                    ),
598                    Err(err) => output_error = Some(err),
599                }
600            } else {
601                output_error = Some(format!(
602                    "missing value for --{}: expected json, yaml, or plain",
603                    output_flag.unwrap_or("output")
604                ));
605            }
606            i += if inline_value.is_some() || value.is_none() {
607                1
608            } else {
609                2
610            };
611            continue;
612        }
613        i += 1;
614    }
615
616    ParsedVersionRequest {
617        version_requested,
618        output_format,
619        output_error,
620    }
621}
622
623fn set_version_output_format(
624    current: &mut Option<OutputFormat>,
625    next: OutputFormat,
626    source: &str,
627    output_error: &mut Option<String>,
628) {
629    if let Some(existing) = current
630        && *existing != next
631    {
632        *output_error = Some(format!(
633            "conflicting output formats: {source} conflicts with previous output format"
634        ));
635        return;
636    }
637    *current = Some(next);
638}
639
640fn version_output_flag_matches(
641    flag_name: Option<&str>,
642    output_flag: Option<&str>,
643    output_short: Option<char>,
644) -> bool {
645    let Some(seen) = flag_name else {
646        return false;
647    };
648    output_flag.is_some_and(|expected| seen == expected)
649        || output_short.is_some_and(|short| {
650            let mut chars = seen.chars();
651            chars.next().is_some_and(|seen_short| seen_short == short) && chars.next().is_none()
652        })
653}
654
655fn normalize_long_flag(flag: &str) -> &str {
656    flag.strip_prefix("--").unwrap_or(flag)
657}
658
659fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
660    if !arg.starts_with('-') || arg == "-" {
661        return (None, None);
662    }
663    let (flag, value) = arg.split_once('=').unwrap_or((arg, ""));
664    let name = flag.trim_start_matches('-');
665    if name.is_empty() {
666        (None, None)
667    } else if arg.contains('=') {
668        (Some(name), Some(value))
669    } else {
670        (Some(name), None)
671    }
672}