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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
17pub enum OutputFormat {
18 Json,
19 Yaml,
20 Plain,
21}
22
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub struct LogFilters(Vec<String>);
31
32impl LogFilters {
33 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 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 pub fn is_empty(&self) -> bool {
67 self.0.is_empty()
68 }
69
70 pub fn as_slice(&self) -> &[String] {
72 &self.0
73 }
74}
75
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum CliProtocolMode {
79 Legacy,
81 ProtocolV1,
83}
84
85#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct VersionConfig {
92 pub default_output: Option<OutputFormat>,
97 pub output_flag: Option<&'static str>,
99 pub output_short: Option<char>,
101 pub allow_output_format: bool,
103 pub protocol_mode: CliProtocolMode,
105}
106
107impl VersionConfig {
108 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 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 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 pub const fn with_default_output(mut self, default_output: Option<OutputFormat>) -> Self {
148 self.default_output = default_output;
149 self
150 }
151
152 pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
154 self.output_flag = flag;
155 self
156 }
157
158 pub const fn with_output_short(mut self, flag: Option<char>) -> Self {
160 self.output_short = flag;
161 self
162 }
163
164 pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
166 self.allow_output_format = enabled;
167 self
168 }
169
170 pub const fn with_protocol_v1(mut self) -> Self {
172 self.protocol_mode = CliProtocolMode::ProtocolV1;
173 self
174 }
175}
176
177pub 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
197pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> LogFilters {
207 LogFilters::new(entries.iter().map(AsRef::as_ref))
208}
209
210pub 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
228pub 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#[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 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 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
289pub 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 pub fn new(writer: W, format: OutputFormat) -> Self {
309 Self::with_options(writer, format, OutputOptions::default())
310 }
311
312 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 pub fn with_strict_protocol(mut self) -> Self {
326 self.strict_protocol = true;
327 self
328 }
329
330 pub fn with_log_fields<F>(mut self, provider: F) -> Self
335 where
336 F: Fn() -> Value + 'static,
337 {
338 self.log_fields_provider = Some(Box::new(provider));
339 self
340 }
341
342 pub fn emit(&mut self, event: Event) -> Result<(), CliEmitterError> {
346 let value = event.into_value();
347 self.write_event(value)
348 }
349
350 pub fn emit_validated_value(&mut self, value: Value) -> Result<(), CliEmitterError> {
354 validate_protocol_event(&value, true).map_err(CliEmitterError::Validation)?;
355 self.write_event(value)
356 }
357
358 pub fn emit_result(&mut self, payload: Value) -> Result<(), CliEmitterError> {
360 #[allow(clippy::expect_used)]
361 self.emit(
362 json_result(payload)
363 .build()
364 .expect("json_result: builder failed unexpectedly"),
365 )
366 }
367
368 pub fn emit_error(&mut self, code: &str, message: &str) -> Result<(), CliEmitterError> {
370 #[allow(clippy::expect_used)]
371 self.emit(
372 json_error(code, message)
373 .build()
374 .expect("json_error: builder failed unexpectedly"),
375 )
376 }
377
378 pub fn emit_progress(&mut self, message: &str) -> Result<(), CliEmitterError> {
380 #[allow(clippy::expect_used)]
381 self.emit(
382 json_progress(serde_json::json!({ "message": message }))
383 .build()
384 .expect("json_progress: builder failed unexpectedly"),
385 )
386 }
387
388 pub fn emit_log(&mut self, level: LogLevel, message: &str) -> Result<(), CliEmitterError> {
392 #[allow(clippy::expect_used)]
393 let mut event = json_log(serde_json::json!({
394 "level": level.as_str(),
395 "message": message,
396 }))
397 .build()
398 .expect("json_log: builder failed unexpectedly")
399 .into_value();
400 if let Some(provider) = &self.log_fields_provider {
401 let provider_fields = provider();
402 if let Some(log_obj) = event.get_mut("log").and_then(|v| v.as_object_mut())
403 && let Value::Object(fields) = provider_fields
404 {
405 for (k, v) in fields {
406 log_obj.entry(k).or_insert(v);
407 }
408 }
409 }
410 self.write_event(event)
411 }
412
413 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#[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
479pub 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
495pub 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}