Skip to main content

agent_first_data/
cli.rs

1use crate::formatting::OutputFormat;
2use crate::protocol::{
3    BuildError, Event, LogLevel, ProtocolViolation, json_error, json_log, json_progress,
4    json_result, validate_protocol_event,
5};
6use crate::redaction::OutputOptions;
7use serde_json::Value;
8
9// ═══════════════════════════════════════════
10// Public API: CLI Helpers
11// ═══════════════════════════════════════════
12
13/// Parsed and normalized log filters (trimmed, lowercased, deduplicated).
14///
15/// Semantics (a stable contract):
16/// - An **empty** set emits no logs (filtering is opt-in, not opt-out).
17/// - The single wildcard word `"all"` emits every log. (`"*"` is not special —
18///   there is one wildcard spelling, not two.)
19/// - Otherwise a log is emitted iff its lowercased event name **starts with**
20///   any filter string (prefix match).
21///
22/// Consequence to know: a mistyped filter simply matches nothing, so it
23/// silently emits no output — that is the documented behavior, not a bug.
24#[derive(Clone, Debug, Default, PartialEq, Eq)]
25pub struct LogFilters(Vec<String>);
26
27impl LogFilters {
28    /// Create a new LogFilters from filter strings. Entries are trimmed,
29    /// lowercased, and de-duplicated; empty entries are dropped.
30    pub fn new<I, S>(filters: I) -> Self
31    where
32        I: IntoIterator<Item = S>,
33        S: AsRef<str>,
34    {
35        let mut out: Vec<String> = Vec::new();
36        for entry in filters {
37            let s = entry.as_ref().trim().to_ascii_lowercase();
38            if !s.is_empty() && !out.contains(&s) {
39                out.push(s);
40            }
41        }
42        Self(out)
43    }
44
45    /// Check if an event should be logged based on these filters.
46    ///
47    /// Returns `false` if empty (no logs). Returns `true` if the set contains
48    /// the wildcard word `"all"`. Otherwise returns `true` iff the lowercased
49    /// event name starts with any filter (prefix match).
50    pub fn enabled(&self, event: &str) -> bool {
51        if self.0.is_empty() {
52            return false;
53        }
54        let event_lower = event.to_ascii_lowercase();
55        if self.0.contains(&"all".to_string()) {
56            return true;
57        }
58        self.0.iter().any(|filter| event_lower.starts_with(filter))
59    }
60
61    /// Check if this filter set is empty (no filters configured).
62    pub fn is_empty(&self) -> bool {
63        self.0.is_empty()
64    }
65
66    /// Access the underlying filter strings as a slice.
67    pub fn as_slice(&self) -> &[String] {
68        &self.0
69    }
70}
71
72/// Parse `--output` flag value into [`OutputFormat`].
73///
74/// Returns `Err` with a message suitable for passing to [`build_cli_error`] on unknown values.
75///
76/// ```
77/// use agent_first_data::{cli_parse_output, OutputFormat};
78/// assert!(matches!(cli_parse_output("json"), Ok(OutputFormat::Json)));
79/// assert!(cli_parse_output("xml").is_err());
80/// ```
81pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
82    match s {
83        "json" => Ok(OutputFormat::Json),
84        "yaml" => Ok(OutputFormat::Yaml),
85        "plain" => Ok(OutputFormat::Plain),
86        _ => Err(format!(
87            "invalid --output format '{s}': expected json, yaml, or plain"
88        )),
89    }
90}
91
92/// Normalize `--log` flag entries: trim, lowercase, deduplicate, remove empty.
93///
94/// Accepts pre-split entries produced by a caller's comma-list parser.
95///
96/// ```
97/// use agent_first_data::{cli_parse_log_filters, LogFilters};
98/// let f = cli_parse_log_filters(&["Query", " error ", "query"]);
99/// assert_eq!(f, LogFilters::new(["query", "error"]));
100/// ```
101pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> LogFilters {
102    LogFilters::new(entries.iter().map(AsRef::as_ref))
103}
104
105/// Error returned by [`CliEmitter`].
106#[derive(Debug)]
107pub enum CliEmitterError {
108    /// A protocol-validation failure.
109    Validation(ProtocolViolation),
110    /// An event builder rejected its inputs (empty code/message, reserved field).
111    Build(BuildError),
112    /// An emitter lifecycle rule was violated (terminal ordering).
113    Lifecycle(String),
114    /// Writing the event to the underlying writer failed.
115    Write(std::io::Error),
116}
117
118impl std::fmt::Display for CliEmitterError {
119    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120        match self {
121            Self::Validation(v) => write!(f, "{v}"),
122            Self::Build(e) => write!(f, "{e}"),
123            Self::Lifecycle(err) => f.write_str(err),
124            Self::Write(err) => write!(f, "failed to write CLI event: {err}"),
125        }
126    }
127}
128
129impl CliEmitterError {
130    /// Return the underlying writer error, when event emission failed during I/O.
131    pub const fn io_error(&self) -> Option<&std::io::Error> {
132        match self {
133            Self::Write(err) => Some(err),
134            Self::Validation(_) | Self::Build(_) | Self::Lifecycle(_) => None,
135        }
136    }
137
138    /// Return the underlying writer error kind, when available.
139    pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
140        self.io_error().map(std::io::Error::kind)
141    }
142}
143
144impl std::error::Error for CliEmitterError {
145    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
146        self.io_error()
147            .map(|err| err as &(dyn std::error::Error + 'static))
148    }
149}
150
151impl From<std::io::Error> for CliEmitterError {
152    fn from(err: std::io::Error) -> Self {
153        Self::Write(err)
154    }
155}
156
157impl From<BuildError> for CliEmitterError {
158    fn from(err: BuildError) -> Self {
159        Self::Build(err)
160    }
161}
162
163/// Where a [`CliEmitter`] sends its events, selected by `--output-to`.
164///
165/// The stream an event lands on follows the program's *consumption mode*, not
166/// the event's shape (see the spec's CLI Event Framing):
167///
168/// - [`OutputTo::Split`] (the default) is finite one-shot mode: `result` goes
169///   to `stdout`, while `error`/`progress`/`log` go to `stderr`. `stdout`
170///   therefore carries only successful payloads, so a shell capture or pipe
171///   never mistakes a failure for data.
172/// - [`OutputTo::Stdout`] / [`OutputTo::Stderr`] are event-stream mode: every
173///   event, including `error`, is collapsed onto that one stream so a consumer
174///   reading it in order (`kind`-branching) sees preserved ordering.
175///
176/// A command is an event stream when it produces more than one caller-needed
177/// output over time — a chunked payload, or an address the caller must act on
178/// before the command can report its outcome. Such a command defaults to
179/// [`OutputTo::Stdout`] and rejects an explicit `split`, because splitting it
180/// would strand the caller's data on the diagnostic stream. If a
181/// `kind:"progress"` event carries a payload the caller must read, the command
182/// is an event stream that has not declared itself.
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub enum OutputTo {
185    /// Finite one-shot: `result` → stdout, `error`/`progress`/`log` → stderr.
186    Split,
187    /// Event stream: every event onto stdout.
188    Stdout,
189    /// Event stream: every event onto stderr.
190    Stderr,
191}
192
193impl OutputTo {
194    /// Parse an `--output-to` value: `split` (default), `stdout`, or `stderr`.
195    pub fn parse(value: &str) -> Result<Self, String> {
196        match value {
197            "split" => Ok(Self::Split),
198            "stdout" => Ok(Self::Stdout),
199            "stderr" => Ok(Self::Stderr),
200            other => Err(format!(
201                "unsupported --output-to `{other}`; expected split, stdout, or stderr"
202            )),
203        }
204    }
205}
206
207/// Stateful emitter for structured CLI executions.
208///
209/// The output format, redaction policy, and stream routing are fixed when the
210/// emitter is created. Emitting after a terminal event, emitting a repeated
211/// terminal event, and writer failures all return explicit errors.
212///
213/// Routing follows the consumption mode ([`OutputTo`]):
214///
215/// - [`CliEmitter::finite`] / [`CliEmitter::finite_with`] — finite one-shot:
216///   `result` → the primary writer (stdout), `error`/`progress`/`log` → the
217///   diagnostic writer (stderr). This is the recommended default for a
218///   one-shot CLI, so shell capture and pipelines never treat a failure as data.
219/// - [`CliEmitter::stream`] — event stream: every event, including `error`,
220///   goes to the single writer, preserving interleaved ordering.
221/// - [`CliEmitter::from_output_to`] builds either shape from a parsed
222///   [`OutputTo`] selector.
223pub struct CliEmitter<W: std::io::Write> {
224    writer: W,
225    diagnostic: Option<Box<dyn std::io::Write>>,
226    format: OutputFormat,
227    output_options: OutputOptions,
228    strict_protocol: bool,
229    terminal_emitted: bool,
230}
231
232impl<W: std::io::Write> CliEmitter<W> {
233    /// Create an event-stream emitter: every event goes to `writer`.
234    ///
235    /// Alias for [`CliEmitter::stream`]. Use [`CliEmitter::finite`] for a
236    /// one-shot command that should split `result`/`error` across stdout/stderr.
237    pub fn new(writer: W, format: OutputFormat) -> Self {
238        Self::stream(writer, format)
239    }
240
241    /// Create an event-stream emitter with custom output options.
242    pub fn with_options(writer: W, format: OutputFormat, output_options: OutputOptions) -> Self {
243        Self {
244            writer,
245            diagnostic: None,
246            format,
247            output_options,
248            strict_protocol: false,
249            terminal_emitted: false,
250        }
251    }
252
253    /// Create an event-stream emitter: every event, including `error`, goes to
254    /// the single `writer`, preserving interleaved ordering. Pick this when the
255    /// consumer reads one ordered stream and branches on `kind`.
256    pub fn stream(writer: W, format: OutputFormat) -> Self {
257        Self::with_options(writer, format, OutputOptions::default())
258    }
259
260    /// Create a finite one-shot emitter with explicit sinks: `result` goes to
261    /// `result_writer`, while `error`/`progress`/`log` go to `diagnostic`.
262    pub fn finite_with(
263        result_writer: W,
264        diagnostic: impl std::io::Write + 'static,
265        format: OutputFormat,
266    ) -> Self {
267        Self::finite_with_options(result_writer, diagnostic, format, OutputOptions::default())
268    }
269
270    /// Create a finite one-shot emitter with explicit sinks and output options.
271    pub fn finite_with_options(
272        result_writer: W,
273        diagnostic: impl std::io::Write + 'static,
274        format: OutputFormat,
275        output_options: OutputOptions,
276    ) -> Self {
277        Self {
278            writer: result_writer,
279            diagnostic: Some(Box::new(diagnostic)),
280            format,
281            output_options,
282            strict_protocol: false,
283            terminal_emitted: false,
284        }
285    }
286
287    /// Require the AFDATA recommended strict profile for every emitted event.
288    pub fn with_strict_protocol(mut self) -> Self {
289        self.strict_protocol = true;
290        self
291    }
292
293    /// Emit a typed Event (unified entry for all event kinds).
294    ///
295    /// Accepts only SDK-constructed Event; for dynamic JSON, use emit_validated_value.
296    pub fn emit(&mut self, event: Event) -> Result<(), CliEmitterError> {
297        let value = event.into_value();
298        self.write_event(value)
299    }
300
301    /// Emit and validate dynamic JSON, then apply redaction/formatting/write.
302    ///
303    /// Runs strict validation first, ensuring the dynamic JSON is safe.
304    pub fn emit_validated_value(&mut self, value: Value) -> Result<(), CliEmitterError> {
305        validate_protocol_event(&value, true).map_err(CliEmitterError::Validation)?;
306        self.write_event(value)
307    }
308
309    /// Convenience: build and emit a result event.
310    pub fn emit_result(&mut self, payload: Value) -> Result<(), CliEmitterError> {
311        self.emit(json_result(payload).build())
312    }
313
314    /// Convenience: build and emit an error event.
315    pub fn emit_error(&mut self, code: &str, message: &str) -> Result<(), CliEmitterError> {
316        self.emit(json_error(code, message).build()?)
317    }
318
319    /// Convenience: build and emit a progress event.
320    pub fn emit_progress(&mut self, message: &str) -> Result<(), CliEmitterError> {
321        self.emit(json_progress(serde_json::json!({ "message": message })).build())
322    }
323
324    /// Convenience: build and emit a log event.
325    pub fn emit_log(&mut self, level: LogLevel, message: &str) -> Result<(), CliEmitterError> {
326        self.emit(
327            json_log(serde_json::json!({
328            "level": level.as_str(),
329            "message": message,
330            }))
331            .build(),
332        )
333    }
334
335    /// Emit `event` as the terminal event and resolve the outcome to a process
336    /// exit code, so a one-shot CLI need not hand-roll the emit-then-exit dance.
337    ///
338    /// A successful write returns `success_code`; a broken pipe (the reader hung
339    /// up) returns `0`; any other write or validation failure returns `4`. A
340    /// library never calls `process::exit` itself — return this code from `main`
341    /// (`std::process::ExitCode::from(code)`).
342    pub fn finish(&mut self, event: Event, success_code: u8) -> u8 {
343        match self.emit(event) {
344            Ok(()) => success_code,
345            Err(err) if err.io_error_kind() == Some(std::io::ErrorKind::BrokenPipe) => 0,
346            Err(_) => 4,
347        }
348    }
349
350    /// Convenience over [`CliEmitter::finish`]: emit a `result` payload and
351    /// return `0` on success.
352    ///
353    /// For an error, build it with [`json_error`] (`.hint(…)`, `.retryable(…)`,
354    /// `.field(…)` as needed) and pass the event to [`CliEmitter::finish`] with
355    /// the desired exit code — the builder is the error type, so no separate
356    /// error-emitting convenience is needed.
357    pub fn finish_result(&mut self, payload: Value) -> u8 {
358        self.finish(json_result(payload).build(), 0)
359    }
360
361    /// Access the underlying writer.
362    pub fn into_inner(self) -> W {
363        self.writer
364    }
365
366    fn write_event(&mut self, event: Value) -> Result<(), CliEmitterError> {
367        validate_protocol_event(&event, self.strict_protocol)
368            .map_err(CliEmitterError::Validation)?;
369        let kind = event.get("kind").and_then(Value::as_str).ok_or_else(|| {
370            CliEmitterError::Validation(ProtocolViolation {
371                rule: "kind_invalid",
372                pointer: "/kind".to_string(),
373                message: "event.kind is required".to_string(),
374            })
375        })?;
376        match kind {
377            "log" | "progress" => {
378                if self.terminal_emitted {
379                    return Err(CliEmitterError::Lifecycle(
380                        "cannot emit non-terminal event after terminal event".to_string(),
381                    ));
382                }
383            }
384            "result" | "error" => {
385                if self.terminal_emitted {
386                    return Err(CliEmitterError::Lifecycle(
387                        "cannot emit duplicate terminal event".to_string(),
388                    ));
389                }
390            }
391            _ => {
392                return Err(CliEmitterError::Validation(ProtocolViolation {
393                    rule: "kind_unsupported",
394                    pointer: "/kind".to_string(),
395                    message: format!("unsupported event kind {kind:?}"),
396                }));
397            }
398        }
399        let rendered = crate::formatting::render(&event, self.format, &self.output_options);
400        // Finite mode (a diagnostic sink is present) splits by kind: `result`
401        // stays on the primary writer (stdout), while `error`/`progress`/`log`
402        // are diagnostics routed to the diagnostic writer (stderr). Event-stream
403        // mode (no diagnostic sink) keeps every event on the single writer.
404        match &mut self.diagnostic {
405            Some(diagnostic) if kind != "result" => {
406                write_event_line(diagnostic.as_mut(), &rendered)
407            }
408            _ => write_event_line(&mut self.writer, &rendered),
409        }?;
410        if matches!(kind, "result" | "error") {
411            self.terminal_emitted = true;
412        }
413        Ok(())
414    }
415}
416
417/// Write one rendered event line (payload plus trailing newline) and flush.
418fn write_event_line(writer: &mut dyn std::io::Write, rendered: &str) -> std::io::Result<()> {
419    writer.write_all(rendered.as_bytes())?;
420    writer.write_all(b"\n")?;
421    writer.flush()
422}
423
424// The emitter's own diagnostic sink is the spec's sanctioned exception to the
425// "no ad-hoc stderr" rule (Channel policy): a finite one-shot emitter routes
426// `error`/`progress`/`log` to `std::io::stderr` on purpose, so these wired
427// constructors are allowed to name it directly.
428#[allow(clippy::disallowed_methods)]
429impl CliEmitter<std::io::Stdout> {
430    /// Create a finite one-shot emitter wired to the process streams: `result`
431    /// → `stdout`, `error`/`progress`/`log` → `stderr`. The recommended default
432    /// for a one-shot CLI.
433    pub fn finite(format: OutputFormat) -> Self {
434        Self::finite_with(std::io::stdout(), std::io::stderr(), format)
435    }
436
437    /// Create a finite one-shot emitter wired to the process streams, with
438    /// custom output options.
439    pub fn finite_options(format: OutputFormat, output_options: OutputOptions) -> Self {
440        Self::finite_with_options(std::io::stdout(), std::io::stderr(), format, output_options)
441    }
442}
443
444// Same sanctioned exception as above: `from_output_to` wires the process
445// streams (`std::io::stderr` included) as the emitter's own sinks.
446#[allow(clippy::disallowed_methods)]
447impl CliEmitter<Box<dyn std::io::Write>> {
448    /// Build an emitter from a parsed [`OutputTo`] selector, wired to the
449    /// process streams: `Split` is finite mode (`result` → stdout, everything
450    /// else → stderr); `Stdout`/`Stderr` are event-stream mode onto that stream.
451    pub fn from_output_to(selector: OutputTo, format: OutputFormat) -> Self {
452        Self::from_output_to_with(selector, format, OutputOptions::default())
453    }
454
455    /// As [`CliEmitter::from_output_to`], with custom output options.
456    pub fn from_output_to_with(
457        selector: OutputTo,
458        format: OutputFormat,
459        output_options: OutputOptions,
460    ) -> Self {
461        match selector {
462            OutputTo::Split => Self::finite_with_options(
463                Box::new(std::io::stdout()),
464                std::io::stderr(),
465                format,
466                output_options,
467            ),
468            OutputTo::Stdout => {
469                Self::with_options(Box::new(std::io::stdout()), format, output_options)
470            }
471            OutputTo::Stderr => {
472                Self::with_options(Box::new(std::io::stderr()), format, output_options)
473            }
474        }
475    }
476}
477
478/// Build a standard CLI version event: a `kind:"result"` event whose payload is
479/// `{ "code": "version", "name": <name>, "version": <version> }`, plus
480/// `"display_name"`/`"build"` when given. `name` is the short/bin identity
481/// (e.g. `"afdata"`); `display_name` is an optional human-facing product name
482/// (e.g. `"Agent-First Data"`); `build` is an opaque caller-supplied identifier (a git
483/// commit SHA, for example) — its meaning is entirely up to the caller. Both
484/// are `None` when unavailable, and simply absent from the payload.
485pub fn build_cli_version(
486    name: &str,
487    display_name: Option<&str>,
488    version: &str,
489    build: Option<&str>,
490) -> Event {
491    let mut payload = serde_json::json!({
492        "code": "version",
493        "name": name,
494        "version": version,
495    });
496    if let Some(display_name) = display_name {
497        payload["display_name"] = Value::String(display_name.to_string());
498    }
499    if let Some(build) = build {
500        payload["build"] = Value::String(build.to_string());
501    }
502    json_result(payload).build()
503}
504
505/// Render a CLI version response as a protocol-v1 event in `format`.
506pub fn cli_render_version(
507    name: &str,
508    display_name: Option<&str>,
509    version: &str,
510    build: Option<&str>,
511    format: OutputFormat,
512) -> String {
513    let mut rendered = crate::formatting::render(
514        build_cli_version(name, display_name, version, build).as_value(),
515        format,
516        &OutputOptions::default(),
517    );
518    while rendered.ends_with('\n') {
519        rendered.pop();
520    }
521    rendered.push('\n');
522    rendered
523}