use crate::formatting::{
output_json, output_json_with_options, output_plain, output_plain_with_options, output_yaml,
output_yaml_with_options,
};
use crate::protocol::{
Event, LogLevel, 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()) || self.0.contains(&"*".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
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CliProtocolMode {
Legacy,
ProtocolV1,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VersionConfig {
pub default_output: Option<OutputFormat>,
pub output_flag: Option<&'static str>,
pub output_short: Option<char>,
pub allow_output_format: bool,
pub protocol_mode: CliProtocolMode,
}
impl VersionConfig {
pub const fn new(default_output: Option<OutputFormat>) -> Self {
Self {
default_output,
output_flag: None,
output_short: None,
allow_output_format: false,
protocol_mode: CliProtocolMode::Legacy,
}
}
pub const fn agent_cli_default() -> Self {
Self {
default_output: Some(OutputFormat::Json),
output_flag: Some("--output"),
output_short: None,
allow_output_format: true,
protocol_mode: CliProtocolMode::Legacy,
}
}
pub const fn conventional_default() -> Self {
Self {
default_output: None,
output_flag: Some("--output"),
output_short: None,
allow_output_format: true,
protocol_mode: CliProtocolMode::Legacy,
}
}
pub const fn with_default_output(mut self, default_output: Option<OutputFormat>) -> Self {
self.default_output = default_output;
self
}
pub const fn with_output_flag(mut self, flag: Option<&'static str>) -> Self {
self.output_flag = flag;
self
}
pub const fn with_output_short(mut self, flag: Option<char>) -> Self {
self.output_short = flag;
self
}
pub const fn with_output_format_override(mut self, enabled: bool) -> Self {
self.allow_output_format = enabled;
self
}
pub const fn with_protocol_v1(mut self) -> Self {
self.protocol_mode = CliProtocolMode::ProtocolV1;
self
}
}
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))
}
pub fn cli_output(value: &Value, format: OutputFormat) -> String {
match format {
OutputFormat::Json => output_json(value),
OutputFormat::Yaml => output_yaml(value),
OutputFormat::Plain => output_plain(value),
}
}
pub fn cli_output_with_options(
value: &Value,
format: OutputFormat,
output_options: &OutputOptions,
) -> String {
match format {
OutputFormat::Json => output_json_with_options(value, output_options),
OutputFormat::Yaml => output_yaml_with_options(value, output_options),
OutputFormat::Plain => output_plain_with_options(value, output_options),
}
}
#[derive(Debug)]
pub enum CliEmitterError {
Validation(String),
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(err) | 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::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)
}
}
pub struct CliEmitter<W: std::io::Write> {
writer: W,
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::with_options(writer, format, OutputOptions::default())
}
pub fn with_options(writer: W, format: OutputFormat, output_options: OutputOptions) -> Self {
Self {
writer,
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> {
#[allow(clippy::expect_used)]
self.emit(
json_result(payload)
.build()
.expect("json_result: builder failed unexpectedly"),
)
}
pub fn emit_error(&mut self, code: &str, message: &str) -> Result<(), CliEmitterError> {
#[allow(clippy::expect_used)]
self.emit(
json_error(code, message)
.build()
.expect("json_error: builder failed unexpectedly"),
)
}
pub fn emit_progress(&mut self, message: &str) -> Result<(), CliEmitterError> {
#[allow(clippy::expect_used)]
self.emit(
json_progress(serde_json::json!({ "message": message }))
.build()
.expect("json_progress: builder failed unexpectedly"),
)
}
pub fn emit_log(&mut self, level: LogLevel, message: &str) -> Result<(), CliEmitterError> {
#[allow(clippy::expect_used)]
let mut event = json_log(serde_json::json!({
"level": level.as_str(),
"message": message,
}))
.build()
.expect("json_log: builder failed unexpectedly")
.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 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("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(format!(
"unsupported event kind {kind:?}"
)));
}
}
let rendered = cli_output_with_options(&event, self.format, &self.output_options);
self.writer.write_all(rendered.as_bytes())?;
self.writer.write_all(b"\n")?;
self.writer.flush()?;
if matches!(kind, "result" | "error") {
self.terminal_emitted = true;
}
Ok(())
}
}
#[allow(clippy::expect_used)]
pub fn build_cli_version(version: &str) -> Event {
json_result(serde_json::json!({ "version": version }))
.build()
.expect("build_cli_version: builder failed unexpectedly")
}
fn build_cli_version_with_mode(version: &str, mode: CliProtocolMode) -> Event {
match mode {
CliProtocolMode::Legacy => build_cli_version(version),
CliProtocolMode::ProtocolV1 => {
let payload = serde_json::json!({ "code": "version", "version": version });
#[allow(clippy::expect_used)]
json_result(payload)
.trace(serde_json::json!({}))
.build()
.expect("build_cli_version_with_mode: builder failed unexpectedly")
}
}
}
pub fn cli_render_version(name: &str, version: &str, format: Option<OutputFormat>) -> String {
let mut rendered = match format {
Some(format) => cli_output(build_cli_version(version).as_value(), format),
None => format!("{name} {version}"),
};
while rendered.ends_with('\n') {
rendered.pop();
}
rendered.push('\n');
rendered
}
pub fn cli_handle_version_or_continue(
raw_args: &[String],
name: &str,
version: &str,
config: &VersionConfig,
) -> Result<Option<String>, Event> {
let parsed = parse_version_request(raw_args, config);
if !parsed.version_requested {
return Ok(None);
}
if let Some(error) = parsed.output_error {
#[allow(clippy::expect_used)]
let event = json_error("cli_error", &error)
.hint_if_some(Some("valid version output formats: json, yaml, plain"))
.build()
.expect("cli_handle_version_or_continue: builder failed");
return Err(event);
}
let format = if config.allow_output_format {
parsed.output_format.or(config.default_output)
} else {
config.default_output
};
if config.protocol_mode == CliProtocolMode::Legacy {
return Ok(Some(cli_render_version(name, version, format)));
}
let Some(format) = format else {
return Ok(Some(cli_render_version(name, version, None)));
};
let mut rendered = cli_output(
build_cli_version_with_mode(version, config.protocol_mode).as_value(),
format,
);
while rendered.ends_with('\n') {
rendered.pop();
}
rendered.push('\n');
Ok(Some(rendered))
}
struct ParsedVersionRequest {
version_requested: bool,
output_format: Option<OutputFormat>,
output_error: Option<String>,
}
fn parse_version_request(raw_args: &[String], config: &VersionConfig) -> 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 output_flag = config.output_flag.map(normalize_long_flag);
let mut i = 0usize;
while i < args.len() {
let arg = args[i].as_str();
if arg == "--" {
break;
}
let (flag_name, inline_value) = split_flag(arg);
if matches!(arg, "--version" | "-V") {
version_requested = true;
i += 1;
continue;
}
if config.allow_output_format && arg == "--json" {
set_version_output_format(
&mut output_format,
OutputFormat::Json,
"--json",
&mut output_error,
);
i += 1;
continue;
}
if config.allow_output_format
&& version_output_flag_matches(flag_name, output_flag, config.output_short)
{
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!("--{} {value}", output_flag.unwrap_or("output")),
&mut output_error,
),
Err(err) => output_error = Some(err),
}
} else {
output_error = Some(format!(
"missing value for --{}: expected json, yaml, or plain",
output_flag.unwrap_or("output")
));
}
i += if inline_value.is_some() || value.is_none() {
1
} else {
2
};
continue;
}
i += 1;
}
ParsedVersionRequest {
version_requested,
output_format,
output_error,
}
}
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);
}
fn version_output_flag_matches(
flag_name: Option<&str>,
output_flag: Option<&str>,
output_short: Option<char>,
) -> bool {
let Some(seen) = flag_name else {
return false;
};
output_flag.is_some_and(|expected| seen == expected)
|| output_short.is_some_and(|short| {
let mut chars = seen.chars();
chars.next().is_some_and(|seen_short| seen_short == short) && chars.next().is_none()
})
}
fn normalize_long_flag(flag: &str) -> &str {
flag.strip_prefix("--").unwrap_or(flag)
}
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)
}
}