1#[cfg(any(feature = "cli", feature = "cli-help"))]
2use crate::protocol::build_cli_error;
3use crate::protocol::{
4 BuildError, Event, LogLevel, ProtocolViolation, json_error, json_log, json_progress,
5 json_result, validate_protocol_event,
6};
7use crate::redaction::OutputOptions;
8use serde_json::Value;
9
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum OutputFormat {
17 Json,
18 Yaml,
20 Plain,
21}
22
23#[derive(Clone, Debug, Default, PartialEq, Eq)]
35pub struct LogFilters(Vec<String>);
36
37impl LogFilters {
38 pub fn new<I, S>(filters: I) -> Self
41 where
42 I: IntoIterator<Item = S>,
43 S: AsRef<str>,
44 {
45 let mut out: Vec<String> = Vec::new();
46 for entry in filters {
47 let s = entry.as_ref().trim().to_ascii_lowercase();
48 if !s.is_empty() && !out.contains(&s) {
49 out.push(s);
50 }
51 }
52 Self(out)
53 }
54
55 pub fn enabled(&self, event: &str) -> bool {
61 if self.0.is_empty() {
62 return false;
63 }
64 let event_lower = event.to_ascii_lowercase();
65 if self.0.contains(&"all".to_string()) {
66 return true;
67 }
68 self.0.iter().any(|filter| event_lower.starts_with(filter))
69 }
70
71 pub fn is_empty(&self) -> bool {
73 self.0.is_empty()
74 }
75
76 pub fn as_slice(&self) -> &[String] {
78 &self.0
79 }
80}
81
82pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
92 match s {
93 "json" => Ok(OutputFormat::Json),
94 "yaml" => Ok(OutputFormat::Yaml),
95 "plain" => Ok(OutputFormat::Plain),
96 _ => Err(format!(
97 "invalid --output format '{s}': expected json, yaml, or plain"
98 )),
99 }
100}
101
102pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> LogFilters {
112 LogFilters::new(entries.iter().map(AsRef::as_ref))
113}
114
115#[derive(Debug)]
117pub enum CliEmitterError {
118 Validation(ProtocolViolation),
120 Build(BuildError),
122 Lifecycle(String),
124 Write(std::io::Error),
126}
127
128impl std::fmt::Display for CliEmitterError {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 match self {
131 Self::Validation(v) => write!(f, "{v}"),
132 Self::Build(e) => write!(f, "{e}"),
133 Self::Lifecycle(err) => f.write_str(err),
134 Self::Write(err) => write!(f, "failed to write CLI event: {err}"),
135 }
136 }
137}
138
139impl CliEmitterError {
140 pub const fn io_error(&self) -> Option<&std::io::Error> {
142 match self {
143 Self::Write(err) => Some(err),
144 Self::Validation(_) | Self::Build(_) | Self::Lifecycle(_) => None,
145 }
146 }
147
148 pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
150 self.io_error().map(std::io::Error::kind)
151 }
152}
153
154impl std::error::Error for CliEmitterError {
155 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
156 self.io_error()
157 .map(|err| err as &(dyn std::error::Error + 'static))
158 }
159}
160
161impl From<std::io::Error> for CliEmitterError {
162 fn from(err: std::io::Error) -> Self {
163 Self::Write(err)
164 }
165}
166
167#[derive(Clone, Copy, Debug, PartialEq, Eq)]
180pub enum OutputTo {
181 Split,
183 Stdout,
185 Stderr,
187}
188
189impl OutputTo {
190 pub fn parse(value: &str) -> Result<Self, String> {
192 match value {
193 "split" => Ok(Self::Split),
194 "stdout" => Ok(Self::Stdout),
195 "stderr" => Ok(Self::Stderr),
196 other => Err(format!(
197 "unsupported --output-to `{other}`; expected split, stdout, or stderr"
198 )),
199 }
200 }
201}
202
203pub struct CliEmitter<W: std::io::Write> {
220 writer: W,
221 diagnostic: Option<Box<dyn std::io::Write>>,
222 format: OutputFormat,
223 output_options: OutputOptions,
224 strict_protocol: bool,
225 terminal_emitted: bool,
226 log_fields_provider: Option<Box<dyn Fn() -> Value>>,
227}
228
229impl<W: std::io::Write> CliEmitter<W> {
230 pub fn new(writer: W, format: OutputFormat) -> Self {
235 Self::stream(writer, format)
236 }
237
238 pub fn with_options(writer: W, format: OutputFormat, output_options: OutputOptions) -> Self {
240 Self {
241 writer,
242 diagnostic: None,
243 format,
244 output_options,
245 strict_protocol: false,
246 terminal_emitted: false,
247 log_fields_provider: None,
248 }
249 }
250
251 pub fn stream(writer: W, format: OutputFormat) -> Self {
255 Self::with_options(writer, format, OutputOptions::default())
256 }
257
258 pub fn finite_with(
261 result_writer: W,
262 diagnostic: impl std::io::Write + 'static,
263 format: OutputFormat,
264 ) -> Self {
265 Self::finite_with_options(result_writer, diagnostic, format, OutputOptions::default())
266 }
267
268 pub fn finite_with_options(
270 result_writer: W,
271 diagnostic: impl std::io::Write + 'static,
272 format: OutputFormat,
273 output_options: OutputOptions,
274 ) -> Self {
275 Self {
276 writer: result_writer,
277 diagnostic: Some(Box::new(diagnostic)),
278 format,
279 output_options,
280 strict_protocol: false,
281 terminal_emitted: false,
282 log_fields_provider: None,
283 }
284 }
285
286 pub fn with_strict_protocol(mut self) -> Self {
288 self.strict_protocol = true;
289 self
290 }
291
292 pub fn with_log_fields<F>(mut self, provider: F) -> Self
297 where
298 F: Fn() -> Value + 'static,
299 {
300 self.log_fields_provider = Some(Box::new(provider));
301 self
302 }
303
304 pub fn emit(&mut self, event: Event) -> Result<(), CliEmitterError> {
308 let value = event.into_value();
309 self.write_event(value)
310 }
311
312 pub fn emit_validated_value(&mut self, value: Value) -> Result<(), CliEmitterError> {
316 validate_protocol_event(&value, true).map_err(CliEmitterError::Validation)?;
317 self.write_event(value)
318 }
319
320 pub fn emit_result(&mut self, payload: Value) -> Result<(), CliEmitterError> {
322 self.emit(json_result(payload).build())
323 }
324
325 pub fn emit_error(&mut self, code: &str, message: &str) -> Result<(), CliEmitterError> {
327 match json_error(code, message).build() {
328 Ok(event) => self.emit(event),
329 Err(err) => Err(CliEmitterError::Build(err)),
330 }
331 }
332
333 pub fn emit_progress(&mut self, message: &str) -> Result<(), CliEmitterError> {
335 self.emit(json_progress(serde_json::json!({ "message": message })).build())
336 }
337
338 pub fn emit_log(&mut self, level: LogLevel, message: &str) -> Result<(), CliEmitterError> {
342 let mut event = json_log(serde_json::json!({
343 "level": level.as_str(),
344 "message": message,
345 }))
346 .build()
347 .into_value();
348 if let Some(provider) = &self.log_fields_provider {
349 let provider_fields = provider();
350 if let Some(log_obj) = event.get_mut("log").and_then(|v| v.as_object_mut())
351 && let Value::Object(fields) = provider_fields
352 {
353 for (k, v) in fields {
354 log_obj.entry(k).or_insert(v);
355 }
356 }
357 }
358 self.write_event(event)
359 }
360
361 pub fn finish(&mut self, event: Event, success_code: u8) -> u8 {
369 match self.emit(event) {
370 Ok(()) => success_code,
371 Err(err) if err.io_error_kind() == Some(std::io::ErrorKind::BrokenPipe) => 0,
372 Err(_) => 4,
373 }
374 }
375
376 pub fn finish_result(&mut self, payload: Value) -> u8 {
384 self.finish(json_result(payload).build(), 0)
385 }
386
387 pub fn into_inner(self) -> W {
389 self.writer
390 }
391
392 fn write_event(&mut self, event: Value) -> Result<(), CliEmitterError> {
393 validate_protocol_event(&event, self.strict_protocol)
394 .map_err(CliEmitterError::Validation)?;
395 let kind = event.get("kind").and_then(Value::as_str).ok_or_else(|| {
396 CliEmitterError::Validation(ProtocolViolation {
397 rule: "kind_invalid",
398 pointer: "/kind".to_string(),
399 message: "event.kind is required".to_string(),
400 })
401 })?;
402 match kind {
403 "log" | "progress" => {
404 if self.terminal_emitted {
405 return Err(CliEmitterError::Lifecycle(
406 "cannot emit non-terminal event after terminal event".to_string(),
407 ));
408 }
409 }
410 "result" | "error" => {
411 if self.terminal_emitted {
412 return Err(CliEmitterError::Lifecycle(
413 "cannot emit duplicate terminal event".to_string(),
414 ));
415 }
416 }
417 _ => {
418 return Err(CliEmitterError::Validation(ProtocolViolation {
419 rule: "kind_unsupported",
420 pointer: "/kind".to_string(),
421 message: format!("unsupported event kind {kind:?}"),
422 }));
423 }
424 }
425 let rendered = crate::formatting::render(&event, self.format, &self.output_options);
426 match &mut self.diagnostic {
431 Some(diagnostic) if kind != "result" => {
432 write_event_line(diagnostic.as_mut(), &rendered)
433 }
434 _ => write_event_line(&mut self.writer, &rendered),
435 }?;
436 if matches!(kind, "result" | "error") {
437 self.terminal_emitted = true;
438 }
439 Ok(())
440 }
441}
442
443fn write_event_line(writer: &mut dyn std::io::Write, rendered: &str) -> std::io::Result<()> {
445 writer.write_all(rendered.as_bytes())?;
446 writer.write_all(b"\n")?;
447 writer.flush()
448}
449
450#[allow(clippy::disallowed_methods)]
455impl CliEmitter<std::io::Stdout> {
456 pub fn finite(format: OutputFormat) -> Self {
460 Self::finite_with(std::io::stdout(), std::io::stderr(), format)
461 }
462
463 pub fn finite_options(format: OutputFormat, output_options: OutputOptions) -> Self {
466 Self::finite_with_options(std::io::stdout(), std::io::stderr(), format, output_options)
467 }
468}
469
470#[allow(clippy::disallowed_methods)]
473impl CliEmitter<Box<dyn std::io::Write>> {
474 pub fn from_output_to(selector: OutputTo, format: OutputFormat) -> Self {
478 Self::from_output_to_with(selector, format, OutputOptions::default())
479 }
480
481 pub fn from_output_to_with(
483 selector: OutputTo,
484 format: OutputFormat,
485 output_options: OutputOptions,
486 ) -> Self {
487 match selector {
488 OutputTo::Split => Self::finite_with_options(
489 Box::new(std::io::stdout()),
490 std::io::stderr(),
491 format,
492 output_options,
493 ),
494 OutputTo::Stdout => {
495 Self::with_options(Box::new(std::io::stdout()), format, output_options)
496 }
497 OutputTo::Stderr => {
498 Self::with_options(Box::new(std::io::stderr()), format, output_options)
499 }
500 }
501 }
502}
503
504pub fn build_cli_version(
512 name: &str,
513 display_name: Option<&str>,
514 version: &str,
515 build: Option<&str>,
516) -> Event {
517 let mut payload = serde_json::json!({
518 "code": "version",
519 "name": name,
520 "version": version,
521 });
522 if let Some(display_name) = display_name {
523 payload["display_name"] = Value::String(display_name.to_string());
524 }
525 if let Some(build) = build {
526 payload["build"] = Value::String(build.to_string());
527 }
528 json_result(payload).build()
529}
530
531pub fn cli_render_version(
533 name: &str,
534 display_name: Option<&str>,
535 version: &str,
536 build: Option<&str>,
537 format: OutputFormat,
538) -> String {
539 let mut rendered = crate::formatting::render(
540 build_cli_version(name, display_name, version, build).as_value(),
541 format,
542 &OutputOptions::default(),
543 );
544 while rendered.ends_with('\n') {
545 rendered.pop();
546 }
547 rendered.push('\n');
548 rendered
549}
550
551#[cfg(any(feature = "cli", feature = "cli-help"))]
576pub fn cli_handle_version_or_continue(
577 raw_args: &[String],
578 cmd: &clap::Command,
579 name: &str,
580 display_name: Option<&str>,
581 version: &str,
582 build: Option<&str>,
583) -> Result<Option<String>, Event> {
584 let parsed = parse_version_request(raw_args, cmd);
585 if !parsed.version_requested {
586 return Ok(None);
587 }
588 if let Some(error) = parsed.output_error {
589 let event = build_cli_error(
590 &error,
591 Some("valid version output formats: json, yaml, plain"),
592 );
593 return Err(event);
594 }
595 Ok(Some(cli_render_version(
596 name,
597 display_name,
598 version,
599 build,
600 parsed
601 .output_format
602 .or_else(|| command_output_default(cmd))
603 .unwrap_or(OutputFormat::Json),
604 )))
605}
606
607#[cfg(any(feature = "cli", feature = "cli-help"))]
608fn command_output_default(cmd: &clap::Command) -> Option<OutputFormat> {
609 cmd.get_arguments()
610 .find(|arg| arg.get_long() == Some("output"))
611 .and_then(|arg| arg.get_default_values().first())
612 .and_then(|value| value.to_str())
613 .and_then(|value| cli_parse_output(value).ok())
614}
615
616#[cfg(any(feature = "cli", feature = "cli-help"))]
617struct ParsedVersionRequest {
618 version_requested: bool,
619 output_format: Option<OutputFormat>,
620 output_error: Option<String>,
621}
622
623#[cfg(any(feature = "cli", feature = "cli-help"))]
624fn parse_version_request(raw_args: &[String], cmd: &clap::Command) -> ParsedVersionRequest {
625 let args = raw_args.get(1..).unwrap_or(&[]);
626 let mut version_requested = false;
627 let mut output_format = None;
628 let mut output_error = None;
629
630 let mut i = 0usize;
631 while i < args.len() {
632 let arg = args[i].as_str();
633 if arg == "--" {
634 break;
635 }
636 if !arg.starts_with('-') {
640 break;
641 }
642
643 let (flag_name, inline_value) = split_flag(arg);
644 if arg == "--version" {
645 version_requested = true;
646 i += 1;
647 continue;
648 }
649
650 if arg == "--json" {
651 set_version_output_format(
652 &mut output_format,
653 OutputFormat::Json,
654 "--json",
655 &mut output_error,
656 );
657 i += 1;
658 continue;
659 }
660
661 if flag_name == Some("output-to") {
665 let has_space_value = inline_value.is_none()
666 && args
667 .get(i + 1)
668 .map(|next| !next.starts_with('-'))
669 .unwrap_or(false);
670 i += if has_space_value { 2 } else { 1 };
671 continue;
672 }
673
674 if flag_name == Some("output") {
675 let value = inline_value.or_else(|| {
676 args.get(i + 1)
677 .map(String::as_str)
678 .filter(|next| !next.starts_with('-'))
679 });
680 if let Some(value) = value {
681 match cli_parse_output(value) {
682 Ok(format) => set_version_output_format(
683 &mut output_format,
684 format,
685 &format!("--output {value}"),
686 &mut output_error,
687 ),
688 Err(err) => output_error = Some(err),
689 }
690 } else {
691 output_error =
692 Some("missing value for --output: expected json, yaml, or plain".to_string());
693 }
694 i += if inline_value.is_some() || value.is_none() {
695 1
696 } else {
697 2
698 };
699 continue;
700 }
701
702 let has_space_value = inline_value.is_none()
707 && args
708 .get(i + 1)
709 .map(|next| !next.starts_with('-'))
710 .unwrap_or(false);
711 i += if has_space_value && flag_takes_value(cmd, arg) {
712 2
713 } else {
714 1
715 };
716 }
717
718 ParsedVersionRequest {
719 version_requested,
720 output_format,
721 output_error,
722 }
723}
724
725#[cfg(any(feature = "cli", feature = "cli-help"))]
726fn set_version_output_format(
727 current: &mut Option<OutputFormat>,
728 next: OutputFormat,
729 source: &str,
730 output_error: &mut Option<String>,
731) {
732 if let Some(existing) = current
733 && *existing != next
734 {
735 *output_error = Some(format!(
736 "conflicting output formats: {source} conflicts with previous output format"
737 ));
738 return;
739 }
740 *current = Some(next);
741}
742
743#[cfg(any(feature = "cli", feature = "cli-help"))]
744fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
745 if !arg.starts_with('-') || arg == "-" {
746 return (None, None);
747 }
748 let (flag, value) = arg.split_once('=').unwrap_or((arg, ""));
749 let name = flag.trim_start_matches('-');
750 if name.is_empty() {
751 (None, None)
752 } else if arg.contains('=') {
753 (Some(name), Some(value))
754 } else {
755 (Some(name), None)
756 }
757}
758
759#[cfg(any(feature = "cli", feature = "cli-help"))]
763fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
764 let Some(flag) = raw_flag.strip_prefix('-') else {
765 return false;
766 };
767 let name = flag.trim_start_matches('-');
768 cmd.get_arguments().any(|arg| {
769 let long_matches = arg.get_long().is_some_and(|long| long == name);
770 let short_matches =
771 name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
772 (long_matches || short_matches)
773 && matches!(
774 arg.get_action(),
775 clap::ArgAction::Set | clap::ArgAction::Append
776 )
777 })
778}