#[cfg(any(feature = "cli", feature = "cli-help"))]
use crate::protocol::build_cli_error;
use crate::protocol::{
BuildError, Event, LogLevel, ProtocolViolation, json_error, json_log, json_progress,
json_result, validate_protocol_event,
};
use crate::redaction::OutputOptions;
use serde_json::Value;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OutputFormat {
Json,
Yaml,
Plain,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LogFilters(Vec<String>);
impl LogFilters {
pub fn new<I, S>(filters: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut out: Vec<String> = Vec::new();
for entry in filters {
let s = entry.as_ref().trim().to_ascii_lowercase();
if !s.is_empty() && !out.contains(&s) {
out.push(s);
}
}
Self(out)
}
pub fn enabled(&self, event: &str) -> bool {
if self.0.is_empty() {
return false;
}
let event_lower = event.to_ascii_lowercase();
if self.0.contains(&"all".to_string()) {
return true;
}
self.0.iter().any(|filter| event_lower.starts_with(filter))
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn as_slice(&self) -> &[String] {
&self.0
}
}
pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
match s {
"json" => Ok(OutputFormat::Json),
"yaml" => Ok(OutputFormat::Yaml),
"plain" => Ok(OutputFormat::Plain),
_ => Err(format!(
"invalid --output format '{s}': expected json, yaml, or plain"
)),
}
}
pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> LogFilters {
LogFilters::new(entries.iter().map(AsRef::as_ref))
}
#[derive(Debug)]
pub enum CliEmitterError {
Validation(ProtocolViolation),
Build(BuildError),
Lifecycle(String),
Write(std::io::Error),
}
impl std::fmt::Display for CliEmitterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Validation(v) => write!(f, "{v}"),
Self::Build(e) => write!(f, "{e}"),
Self::Lifecycle(err) => f.write_str(err),
Self::Write(err) => write!(f, "failed to write CLI event: {err}"),
}
}
}
impl CliEmitterError {
pub const fn io_error(&self) -> Option<&std::io::Error> {
match self {
Self::Write(err) => Some(err),
Self::Validation(_) | Self::Build(_) | Self::Lifecycle(_) => None,
}
}
pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
self.io_error().map(std::io::Error::kind)
}
}
impl std::error::Error for CliEmitterError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.io_error()
.map(|err| err as &(dyn std::error::Error + 'static))
}
}
impl From<std::io::Error> for CliEmitterError {
fn from(err: std::io::Error) -> Self {
Self::Write(err)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OutputTo {
Split,
Stdout,
Stderr,
}
impl OutputTo {
pub fn parse(value: &str) -> Result<Self, String> {
match value {
"split" => Ok(Self::Split),
"stdout" => Ok(Self::Stdout),
"stderr" => Ok(Self::Stderr),
other => Err(format!(
"unsupported --output-to `{other}`; expected split, stdout, or stderr"
)),
}
}
}
pub struct CliEmitter<W: std::io::Write> {
writer: W,
diagnostic: Option<Box<dyn std::io::Write>>,
format: OutputFormat,
output_options: OutputOptions,
strict_protocol: bool,
terminal_emitted: bool,
log_fields_provider: Option<Box<dyn Fn() -> Value>>,
}
impl<W: std::io::Write> CliEmitter<W> {
pub fn new(writer: W, format: OutputFormat) -> Self {
Self::stream(writer, format)
}
pub fn with_options(writer: W, format: OutputFormat, output_options: OutputOptions) -> Self {
Self {
writer,
diagnostic: None,
format,
output_options,
strict_protocol: false,
terminal_emitted: false,
log_fields_provider: None,
}
}
pub fn stream(writer: W, format: OutputFormat) -> Self {
Self::with_options(writer, format, OutputOptions::default())
}
pub fn finite_with(
result_writer: W,
diagnostic: impl std::io::Write + 'static,
format: OutputFormat,
) -> Self {
Self::finite_with_options(result_writer, diagnostic, format, OutputOptions::default())
}
pub fn finite_with_options(
result_writer: W,
diagnostic: impl std::io::Write + 'static,
format: OutputFormat,
output_options: OutputOptions,
) -> Self {
Self {
writer: result_writer,
diagnostic: Some(Box::new(diagnostic)),
format,
output_options,
strict_protocol: false,
terminal_emitted: false,
log_fields_provider: None,
}
}
pub fn with_strict_protocol(mut self) -> Self {
self.strict_protocol = true;
self
}
pub fn with_log_fields<F>(mut self, provider: F) -> Self
where
F: Fn() -> Value + 'static,
{
self.log_fields_provider = Some(Box::new(provider));
self
}
pub fn emit(&mut self, event: Event) -> Result<(), CliEmitterError> {
let value = event.into_value();
self.write_event(value)
}
pub fn emit_validated_value(&mut self, value: Value) -> Result<(), CliEmitterError> {
validate_protocol_event(&value, true).map_err(CliEmitterError::Validation)?;
self.write_event(value)
}
pub fn emit_result(&mut self, payload: Value) -> Result<(), CliEmitterError> {
self.emit(json_result(payload).build())
}
pub fn emit_error(&mut self, code: &str, message: &str) -> Result<(), CliEmitterError> {
match json_error(code, message).build() {
Ok(event) => self.emit(event),
Err(err) => Err(CliEmitterError::Build(err)),
}
}
pub fn emit_progress(&mut self, message: &str) -> Result<(), CliEmitterError> {
self.emit(json_progress(serde_json::json!({ "message": message })).build())
}
pub fn emit_log(&mut self, level: LogLevel, message: &str) -> Result<(), CliEmitterError> {
let mut event = json_log(serde_json::json!({
"level": level.as_str(),
"message": message,
}))
.build()
.into_value();
if let Some(provider) = &self.log_fields_provider {
let provider_fields = provider();
if let Some(log_obj) = event.get_mut("log").and_then(|v| v.as_object_mut())
&& let Value::Object(fields) = provider_fields
{
for (k, v) in fields {
log_obj.entry(k).or_insert(v);
}
}
}
self.write_event(event)
}
pub fn finish(&mut self, event: Event, success_code: u8) -> u8 {
match self.emit(event) {
Ok(()) => success_code,
Err(err) if err.io_error_kind() == Some(std::io::ErrorKind::BrokenPipe) => 0,
Err(_) => 4,
}
}
pub fn finish_result(&mut self, payload: Value) -> u8 {
self.finish(json_result(payload).build(), 0)
}
pub fn into_inner(self) -> W {
self.writer
}
fn write_event(&mut self, event: Value) -> Result<(), CliEmitterError> {
validate_protocol_event(&event, self.strict_protocol)
.map_err(CliEmitterError::Validation)?;
let kind = event.get("kind").and_then(Value::as_str).ok_or_else(|| {
CliEmitterError::Validation(ProtocolViolation {
rule: "kind_invalid",
pointer: "/kind".to_string(),
message: "event.kind is required".to_string(),
})
})?;
match kind {
"log" | "progress" => {
if self.terminal_emitted {
return Err(CliEmitterError::Lifecycle(
"cannot emit non-terminal event after terminal event".to_string(),
));
}
}
"result" | "error" => {
if self.terminal_emitted {
return Err(CliEmitterError::Lifecycle(
"cannot emit duplicate terminal event".to_string(),
));
}
}
_ => {
return Err(CliEmitterError::Validation(ProtocolViolation {
rule: "kind_unsupported",
pointer: "/kind".to_string(),
message: format!("unsupported event kind {kind:?}"),
}));
}
}
let rendered = crate::formatting::render(&event, self.format, &self.output_options);
match &mut self.diagnostic {
Some(diagnostic) if kind != "result" => {
write_event_line(diagnostic.as_mut(), &rendered)
}
_ => write_event_line(&mut self.writer, &rendered),
}?;
if matches!(kind, "result" | "error") {
self.terminal_emitted = true;
}
Ok(())
}
}
fn write_event_line(writer: &mut dyn std::io::Write, rendered: &str) -> std::io::Result<()> {
writer.write_all(rendered.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()
}
#[allow(clippy::disallowed_methods)]
impl CliEmitter<std::io::Stdout> {
pub fn finite(format: OutputFormat) -> Self {
Self::finite_with(std::io::stdout(), std::io::stderr(), format)
}
pub fn finite_options(format: OutputFormat, output_options: OutputOptions) -> Self {
Self::finite_with_options(std::io::stdout(), std::io::stderr(), format, output_options)
}
}
#[allow(clippy::disallowed_methods)]
impl CliEmitter<Box<dyn std::io::Write>> {
pub fn from_output_to(selector: OutputTo, format: OutputFormat) -> Self {
Self::from_output_to_with(selector, format, OutputOptions::default())
}
pub fn from_output_to_with(
selector: OutputTo,
format: OutputFormat,
output_options: OutputOptions,
) -> Self {
match selector {
OutputTo::Split => Self::finite_with_options(
Box::new(std::io::stdout()),
std::io::stderr(),
format,
output_options,
),
OutputTo::Stdout => {
Self::with_options(Box::new(std::io::stdout()), format, output_options)
}
OutputTo::Stderr => {
Self::with_options(Box::new(std::io::stderr()), format, output_options)
}
}
}
}
pub fn build_cli_version(
name: &str,
display_name: Option<&str>,
version: &str,
build: Option<&str>,
) -> Event {
let mut payload = serde_json::json!({
"code": "version",
"name": name,
"version": version,
});
if let Some(display_name) = display_name {
payload["display_name"] = Value::String(display_name.to_string());
}
if let Some(build) = build {
payload["build"] = Value::String(build.to_string());
}
json_result(payload).build()
}
pub fn cli_render_version(
name: &str,
display_name: Option<&str>,
version: &str,
build: Option<&str>,
format: OutputFormat,
) -> String {
let mut rendered = crate::formatting::render(
build_cli_version(name, display_name, version, build).as_value(),
format,
&OutputOptions::default(),
);
while rendered.ends_with('\n') {
rendered.pop();
}
rendered.push('\n');
rendered
}
#[cfg(any(feature = "cli", feature = "cli-help"))]
pub fn cli_handle_version_or_continue(
raw_args: &[String],
cmd: &clap::Command,
name: &str,
display_name: Option<&str>,
version: &str,
build: Option<&str>,
) -> Result<Option<String>, Event> {
let parsed = parse_version_request(raw_args, cmd);
if !parsed.version_requested {
return Ok(None);
}
if let Some(error) = parsed.output_error {
let event = build_cli_error(
&error,
Some("valid version output formats: json, yaml, plain"),
);
return Err(event);
}
Ok(Some(cli_render_version(
name,
display_name,
version,
build,
parsed
.output_format
.or_else(|| command_output_default(cmd))
.unwrap_or(OutputFormat::Json),
)))
}
#[cfg(any(feature = "cli", feature = "cli-help"))]
fn command_output_default(cmd: &clap::Command) -> Option<OutputFormat> {
cmd.get_arguments()
.find(|arg| arg.get_long() == Some("output"))
.and_then(|arg| arg.get_default_values().first())
.and_then(|value| value.to_str())
.and_then(|value| cli_parse_output(value).ok())
}
#[cfg(any(feature = "cli", feature = "cli-help"))]
struct ParsedVersionRequest {
version_requested: bool,
output_format: Option<OutputFormat>,
output_error: Option<String>,
}
#[cfg(any(feature = "cli", feature = "cli-help"))]
fn parse_version_request(raw_args: &[String], cmd: &clap::Command) -> ParsedVersionRequest {
let args = raw_args.get(1..).unwrap_or(&[]);
let mut version_requested = false;
let mut output_format = None;
let mut output_error = None;
let mut i = 0usize;
while i < args.len() {
let arg = args[i].as_str();
if arg == "--" {
break;
}
if !arg.starts_with('-') {
break;
}
let (flag_name, inline_value) = split_flag(arg);
if arg == "--version" {
version_requested = true;
i += 1;
continue;
}
if arg == "--json" {
set_version_output_format(
&mut output_format,
OutputFormat::Json,
"--json",
&mut output_error,
);
i += 1;
continue;
}
if flag_name == Some("output-to") {
let has_space_value = inline_value.is_none()
&& args
.get(i + 1)
.map(|next| !next.starts_with('-'))
.unwrap_or(false);
i += if has_space_value { 2 } else { 1 };
continue;
}
if flag_name == Some("output") {
let value = inline_value.or_else(|| {
args.get(i + 1)
.map(String::as_str)
.filter(|next| !next.starts_with('-'))
});
if let Some(value) = value {
match cli_parse_output(value) {
Ok(format) => set_version_output_format(
&mut output_format,
format,
&format!("--output {value}"),
&mut output_error,
),
Err(err) => output_error = Some(err),
}
} else {
output_error =
Some("missing value for --output: expected json, yaml, or plain".to_string());
}
i += if inline_value.is_some() || value.is_none() {
1
} else {
2
};
continue;
}
let has_space_value = inline_value.is_none()
&& args
.get(i + 1)
.map(|next| !next.starts_with('-'))
.unwrap_or(false);
i += if has_space_value && flag_takes_value(cmd, arg) {
2
} else {
1
};
}
ParsedVersionRequest {
version_requested,
output_format,
output_error,
}
}
#[cfg(any(feature = "cli", feature = "cli-help"))]
fn set_version_output_format(
current: &mut Option<OutputFormat>,
next: OutputFormat,
source: &str,
output_error: &mut Option<String>,
) {
if let Some(existing) = current
&& *existing != next
{
*output_error = Some(format!(
"conflicting output formats: {source} conflicts with previous output format"
));
return;
}
*current = Some(next);
}
#[cfg(any(feature = "cli", feature = "cli-help"))]
fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
if !arg.starts_with('-') || arg == "-" {
return (None, None);
}
let (flag, value) = arg.split_once('=').unwrap_or((arg, ""));
let name = flag.trim_start_matches('-');
if name.is_empty() {
(None, None)
} else if arg.contains('=') {
(Some(name), Some(value))
} else {
(Some(name), None)
}
}
#[cfg(any(feature = "cli", feature = "cli-help"))]
fn flag_takes_value(cmd: &clap::Command, raw_flag: &str) -> bool {
let Some(flag) = raw_flag.strip_prefix('-') else {
return false;
};
let name = flag.trim_start_matches('-');
cmd.get_arguments().any(|arg| {
let long_matches = arg.get_long().is_some_and(|long| long == name);
let short_matches =
name.len() == 1 && arg.get_short().is_some_and(|short| name.starts_with(short));
(long_matches || short_matches)
&& matches!(
arg.get_action(),
clap::ArgAction::Set | clap::ArgAction::Append
)
})
}