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/// Stateful emitter for finite structured CLI executions.
166///
167/// The output format and redaction policy are fixed when the emitter is
168/// created. Emitting after a terminal event, emitting a repeated terminal
169/// event, and writer failures all return explicit errors.
170///
171/// 0.16 API: Accepts typed Event, provides semantic convenience methods,
172/// and supports per-log default field provider.
173pub struct CliEmitter<W: std::io::Write> {
174    writer: W,
175    format: OutputFormat,
176    output_options: OutputOptions,
177    strict_protocol: bool,
178    terminal_emitted: bool,
179    log_fields_provider: Option<Box<dyn Fn() -> Value>>,
180}
181
182impl<W: std::io::Write> CliEmitter<W> {
183    /// Create a new CLI emitter with default output options.
184    pub fn new(writer: W, format: OutputFormat) -> Self {
185        Self::with_options(writer, format, OutputOptions::default())
186    }
187
188    /// Create a new CLI emitter with custom output options.
189    pub fn with_options(writer: W, format: OutputFormat, output_options: OutputOptions) -> Self {
190        Self {
191            writer,
192            format,
193            output_options,
194            strict_protocol: false,
195            terminal_emitted: false,
196            log_fields_provider: None,
197        }
198    }
199
200    /// Require the AFDATA recommended strict profile for every emitted event.
201    pub fn with_strict_protocol(mut self) -> Self {
202        self.strict_protocol = true;
203        self
204    }
205
206    /// Set a provider for default log fields.
207    ///
208    /// The provider is called for every log event (via emit_log or emit with kind:log).
209    /// Its output is merged as extension fields; explicit call-site fields take precedence.
210    pub fn with_log_fields<F>(mut self, provider: F) -> Self
211    where
212        F: Fn() -> Value + 'static,
213    {
214        self.log_fields_provider = Some(Box::new(provider));
215        self
216    }
217
218    /// Emit a typed Event (unified entry for all event kinds).
219    ///
220    /// Accepts only SDK-constructed Event; for dynamic JSON, use emit_validated_value.
221    pub fn emit(&mut self, event: Event) -> Result<(), CliEmitterError> {
222        let value = event.into_value();
223        self.write_event(value)
224    }
225
226    /// Emit and validate dynamic JSON, then apply redaction/formatting/write.
227    ///
228    /// Runs strict validation first, ensuring the dynamic JSON is safe.
229    pub fn emit_validated_value(&mut self, value: Value) -> Result<(), CliEmitterError> {
230        validate_protocol_event(&value, true).map_err(CliEmitterError::Validation)?;
231        self.write_event(value)
232    }
233
234    /// Convenience: build and emit a result event.
235    pub fn emit_result(&mut self, payload: Value) -> Result<(), CliEmitterError> {
236        self.emit(json_result(payload).build())
237    }
238
239    /// Convenience: build and emit an error event.
240    pub fn emit_error(&mut self, code: &str, message: &str) -> Result<(), CliEmitterError> {
241        match json_error(code, message).build() {
242            Ok(event) => self.emit(event),
243            Err(err) => Err(CliEmitterError::Build(err)),
244        }
245    }
246
247    /// Convenience: build and emit a progress event.
248    pub fn emit_progress(&mut self, message: &str) -> Result<(), CliEmitterError> {
249        self.emit(json_progress(serde_json::json!({ "message": message })).build())
250    }
251
252    /// Convenience: build and emit a log event with default fields.
253    ///
254    /// Applies log_fields_provider if configured; explicit fields take precedence.
255    pub fn emit_log(&mut self, level: LogLevel, message: &str) -> Result<(), CliEmitterError> {
256        let mut event = json_log(serde_json::json!({
257            "level": level.as_str(),
258            "message": message,
259        }))
260        .build()
261        .into_value();
262        if let Some(provider) = &self.log_fields_provider {
263            let provider_fields = provider();
264            if let Some(log_obj) = event.get_mut("log").and_then(|v| v.as_object_mut())
265                && let Value::Object(fields) = provider_fields
266            {
267                for (k, v) in fields {
268                    log_obj.entry(k).or_insert(v);
269                }
270            }
271        }
272        self.write_event(event)
273    }
274
275    /// Access the underlying writer.
276    pub fn into_inner(self) -> W {
277        self.writer
278    }
279
280    fn write_event(&mut self, event: Value) -> Result<(), CliEmitterError> {
281        validate_protocol_event(&event, self.strict_protocol)
282            .map_err(CliEmitterError::Validation)?;
283        let kind = event.get("kind").and_then(Value::as_str).ok_or_else(|| {
284            CliEmitterError::Validation(ProtocolViolation {
285                rule: "kind_invalid",
286                pointer: "/kind".to_string(),
287                message: "event.kind is required".to_string(),
288            })
289        })?;
290        match kind {
291            "log" | "progress" => {
292                if self.terminal_emitted {
293                    return Err(CliEmitterError::Lifecycle(
294                        "cannot emit non-terminal event after terminal event".to_string(),
295                    ));
296                }
297            }
298            "result" | "error" => {
299                if self.terminal_emitted {
300                    return Err(CliEmitterError::Lifecycle(
301                        "cannot emit duplicate terminal event".to_string(),
302                    ));
303                }
304            }
305            _ => {
306                return Err(CliEmitterError::Validation(ProtocolViolation {
307                    rule: "kind_unsupported",
308                    pointer: "/kind".to_string(),
309                    message: format!("unsupported event kind {kind:?}"),
310                }));
311            }
312        }
313        let rendered = crate::formatting::render(&event, self.format, &self.output_options);
314        self.writer.write_all(rendered.as_bytes())?;
315        self.writer.write_all(b"\n")?;
316        self.writer.flush()?;
317        if matches!(kind, "result" | "error") {
318            self.terminal_emitted = true;
319        }
320        Ok(())
321    }
322}
323
324/// Build a standard CLI version event: a `kind:"result"` event whose payload
325/// is `{ "code": "version", "version": <version> }`.
326pub fn build_cli_version(version: &str) -> Event {
327    json_result(serde_json::json!({ "code": "version", "version": version })).build()
328}
329
330/// Render a CLI version response.
331///
332/// Pass `Some(format)` for an AFDATA event in JSON/YAML/plain. Pass `None` to
333/// preserve conventional `<name> <version>` output.
334pub fn cli_render_version(name: &str, version: &str, format: Option<OutputFormat>) -> String {
335    let mut rendered = match format {
336        Some(format) => crate::formatting::render(
337            build_cli_version(version).as_value(),
338            format,
339            &OutputOptions::default(),
340        ),
341        None => format!("{name} {version}"),
342    };
343    while rendered.ends_with('\n') {
344        rendered.pop();
345    }
346    rendered.push('\n');
347    rendered
348}
349
350/// Render version output from raw argv if `--version` or `-V` is present.
351///
352/// `raw_args` should be the full argv vector, including argv[0], as produced by
353/// `std::env::args()`. The helper intentionally runs before clap or another
354/// parser so explicit `--output json|yaml|plain` is honored instead of being
355/// bypassed by built-in version handling.
356///
357/// Only a *top-level* version request is recognized: scanning stops at the first
358/// positional argument (the subcommand), so `tool sub --version <value>` leaves
359/// `--version` for the subcommand's parser rather than printing the tool version.
360///
361/// The one blessed behavior: a bare `--version` prints conventional
362/// `<name> <version>` text; an explicit `--output json|yaml|plain` (or `--json`)
363/// prints a protocol-v1 `kind:"result"` version event (payload
364/// `{ "code": "version", "version": ... }`). Returns a standard
365/// [`build_cli_error`] event when the request is malformed, for example
366/// `--version --output xml`.
367pub fn cli_handle_version_or_continue(
368    raw_args: &[String],
369    name: &str,
370    version: &str,
371) -> Result<Option<String>, Event> {
372    let parsed = parse_version_request(raw_args);
373    if !parsed.version_requested {
374        return Ok(None);
375    }
376    if let Some(error) = parsed.output_error {
377        let event = build_cli_error(
378            &error,
379            Some("valid version output formats: json, yaml, plain"),
380        );
381        return Err(event);
382    }
383    Ok(Some(cli_render_version(
384        name,
385        version,
386        parsed.output_format,
387    )))
388}
389
390struct ParsedVersionRequest {
391    version_requested: bool,
392    output_format: Option<OutputFormat>,
393    output_error: Option<String>,
394}
395
396fn parse_version_request(raw_args: &[String]) -> ParsedVersionRequest {
397    let args = raw_args.get(1..).unwrap_or(&[]);
398    let mut version_requested = false;
399    let mut output_format = None;
400    let mut output_error = None;
401
402    let mut i = 0usize;
403    while i < args.len() {
404        let arg = args[i].as_str();
405        if arg == "--" {
406            break;
407        }
408        // The first positional argument marks the subcommand boundary. Past it,
409        // `--version` (and `-V`) belong to the subcommand's own parser, matching
410        // git/cargo/clap: the pre-parser only owns a top-level version request.
411        if !arg.starts_with('-') {
412            break;
413        }
414
415        let (flag_name, inline_value) = split_flag(arg);
416        if matches!(arg, "--version" | "-V") {
417            version_requested = true;
418            i += 1;
419            continue;
420        }
421
422        if arg == "--json" {
423            set_version_output_format(
424                &mut output_format,
425                OutputFormat::Json,
426                "--json",
427                &mut output_error,
428            );
429            i += 1;
430            continue;
431        }
432
433        if flag_name == Some("output") {
434            let value = inline_value.or_else(|| {
435                args.get(i + 1)
436                    .map(String::as_str)
437                    .filter(|next| !next.starts_with('-'))
438            });
439            if let Some(value) = value {
440                match cli_parse_output(value) {
441                    Ok(format) => set_version_output_format(
442                        &mut output_format,
443                        format,
444                        &format!("--output {value}"),
445                        &mut output_error,
446                    ),
447                    Err(err) => output_error = Some(err),
448                }
449            } else {
450                output_error =
451                    Some("missing value for --output: expected json, yaml, or plain".to_string());
452            }
453            i += if inline_value.is_some() || value.is_none() {
454                1
455            } else {
456                2
457            };
458            continue;
459        }
460        i += 1;
461    }
462
463    ParsedVersionRequest {
464        version_requested,
465        output_format,
466        output_error,
467    }
468}
469
470fn set_version_output_format(
471    current: &mut Option<OutputFormat>,
472    next: OutputFormat,
473    source: &str,
474    output_error: &mut Option<String>,
475) {
476    if let Some(existing) = current
477        && *existing != next
478    {
479        *output_error = Some(format!(
480            "conflicting output formats: {source} conflicts with previous output format"
481        ));
482        return;
483    }
484    *current = Some(next);
485}
486
487fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
488    if !arg.starts_with('-') || arg == "-" {
489        return (None, None);
490    }
491    let (flag, value) = arg.split_once('=').unwrap_or((arg, ""));
492    let name = flag.trim_start_matches('-');
493    if name.is_empty() {
494        (None, None)
495    } else if arg.contains('=') {
496        (Some(name), Some(value))
497    } else {
498        (Some(name), None)
499    }
500}