use crate::formatting::OutputFormat;
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, 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)
}
}
impl From<BuildError> for CliEmitterError {
fn from(err: BuildError) -> Self {
Self::Build(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,
}
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,
}
}
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,
}
}
pub fn with_strict_protocol(mut self) -> Self {
self.strict_protocol = true;
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> {
self.emit(json_error(code, message).build()?)
}
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> {
self.emit(
json_log(serde_json::json!({
"level": level.as_str(),
"message": message,
}))
.build(),
)
}
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
}