1use crate::protocol::{
2 BuildError, Event, LogLevel, ProtocolViolation, build_cli_error, json_error, json_log,
3 json_progress, json_result, validate_protocol_event,
4};
5use crate::redaction::OutputOptions;
6use serde_json::Value;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum OutputFormat {
15 Json,
16 Yaml,
18 Plain,
19}
20
21#[derive(Clone, Debug, Default, PartialEq, Eq)]
33pub struct LogFilters(Vec<String>);
34
35impl LogFilters {
36 pub fn new<I, S>(filters: I) -> Self
39 where
40 I: IntoIterator<Item = S>,
41 S: AsRef<str>,
42 {
43 let mut out: Vec<String> = Vec::new();
44 for entry in filters {
45 let s = entry.as_ref().trim().to_ascii_lowercase();
46 if !s.is_empty() && !out.contains(&s) {
47 out.push(s);
48 }
49 }
50 Self(out)
51 }
52
53 pub fn enabled(&self, event: &str) -> bool {
59 if self.0.is_empty() {
60 return false;
61 }
62 let event_lower = event.to_ascii_lowercase();
63 if self.0.contains(&"all".to_string()) {
64 return true;
65 }
66 self.0.iter().any(|filter| event_lower.starts_with(filter))
67 }
68
69 pub fn is_empty(&self) -> bool {
71 self.0.is_empty()
72 }
73
74 pub fn as_slice(&self) -> &[String] {
76 &self.0
77 }
78}
79
80pub fn cli_parse_output(s: &str) -> Result<OutputFormat, String> {
90 match s {
91 "json" => Ok(OutputFormat::Json),
92 "yaml" => Ok(OutputFormat::Yaml),
93 "plain" => Ok(OutputFormat::Plain),
94 _ => Err(format!(
95 "invalid --output format '{s}': expected json, yaml, or plain"
96 )),
97 }
98}
99
100pub fn cli_parse_log_filters<S: AsRef<str>>(entries: &[S]) -> LogFilters {
110 LogFilters::new(entries.iter().map(AsRef::as_ref))
111}
112
113#[derive(Debug)]
115pub enum CliEmitterError {
116 Validation(ProtocolViolation),
118 Build(BuildError),
120 Lifecycle(String),
122 Write(std::io::Error),
124}
125
126impl std::fmt::Display for CliEmitterError {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 match self {
129 Self::Validation(v) => write!(f, "{v}"),
130 Self::Build(e) => write!(f, "{e}"),
131 Self::Lifecycle(err) => f.write_str(err),
132 Self::Write(err) => write!(f, "failed to write CLI event: {err}"),
133 }
134 }
135}
136
137impl CliEmitterError {
138 pub const fn io_error(&self) -> Option<&std::io::Error> {
140 match self {
141 Self::Write(err) => Some(err),
142 Self::Validation(_) | Self::Build(_) | Self::Lifecycle(_) => None,
143 }
144 }
145
146 pub fn io_error_kind(&self) -> Option<std::io::ErrorKind> {
148 self.io_error().map(std::io::Error::kind)
149 }
150}
151
152impl std::error::Error for CliEmitterError {
153 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
154 self.io_error()
155 .map(|err| err as &(dyn std::error::Error + 'static))
156 }
157}
158
159impl From<std::io::Error> for CliEmitterError {
160 fn from(err: std::io::Error) -> Self {
161 Self::Write(err)
162 }
163}
164
165pub struct CliEmitter<W: std::io::Write> {
174 writer: W,
175 format: OutputFormat,
176 output_options: OutputOptions,
177 strict_protocol: bool,
178 terminal_emitted: bool,
179 log_fields_provider: Option<Box<dyn Fn() -> Value>>,
180}
181
182impl<W: std::io::Write> CliEmitter<W> {
183 pub fn new(writer: W, format: OutputFormat) -> Self {
185 Self::with_options(writer, format, OutputOptions::default())
186 }
187
188 pub fn with_options(writer: W, format: OutputFormat, output_options: OutputOptions) -> Self {
190 Self {
191 writer,
192 format,
193 output_options,
194 strict_protocol: false,
195 terminal_emitted: false,
196 log_fields_provider: None,
197 }
198 }
199
200 pub fn with_strict_protocol(mut self) -> Self {
202 self.strict_protocol = true;
203 self
204 }
205
206 pub fn with_log_fields<F>(mut self, provider: F) -> Self
211 where
212 F: Fn() -> Value + 'static,
213 {
214 self.log_fields_provider = Some(Box::new(provider));
215 self
216 }
217
218 pub fn emit(&mut self, event: Event) -> Result<(), CliEmitterError> {
222 let value = event.into_value();
223 self.write_event(value)
224 }
225
226 pub fn emit_validated_value(&mut self, value: Value) -> Result<(), CliEmitterError> {
230 validate_protocol_event(&value, true).map_err(CliEmitterError::Validation)?;
231 self.write_event(value)
232 }
233
234 pub fn emit_result(&mut self, payload: Value) -> Result<(), CliEmitterError> {
236 self.emit(json_result(payload).build())
237 }
238
239 pub fn emit_error(&mut self, code: &str, message: &str) -> Result<(), CliEmitterError> {
241 match json_error(code, message).build() {
242 Ok(event) => self.emit(event),
243 Err(err) => Err(CliEmitterError::Build(err)),
244 }
245 }
246
247 pub fn emit_progress(&mut self, message: &str) -> Result<(), CliEmitterError> {
249 self.emit(json_progress(serde_json::json!({ "message": message })).build())
250 }
251
252 pub fn emit_log(&mut self, level: LogLevel, message: &str) -> Result<(), CliEmitterError> {
256 let mut event = json_log(serde_json::json!({
257 "level": level.as_str(),
258 "message": message,
259 }))
260 .build()
261 .into_value();
262 if let Some(provider) = &self.log_fields_provider {
263 let provider_fields = provider();
264 if let Some(log_obj) = event.get_mut("log").and_then(|v| v.as_object_mut())
265 && let Value::Object(fields) = provider_fields
266 {
267 for (k, v) in fields {
268 log_obj.entry(k).or_insert(v);
269 }
270 }
271 }
272 self.write_event(event)
273 }
274
275 pub fn into_inner(self) -> W {
277 self.writer
278 }
279
280 fn write_event(&mut self, event: Value) -> Result<(), CliEmitterError> {
281 validate_protocol_event(&event, self.strict_protocol)
282 .map_err(CliEmitterError::Validation)?;
283 let kind = event.get("kind").and_then(Value::as_str).ok_or_else(|| {
284 CliEmitterError::Validation(ProtocolViolation {
285 rule: "kind_invalid",
286 pointer: "/kind".to_string(),
287 message: "event.kind is required".to_string(),
288 })
289 })?;
290 match kind {
291 "log" | "progress" => {
292 if self.terminal_emitted {
293 return Err(CliEmitterError::Lifecycle(
294 "cannot emit non-terminal event after terminal event".to_string(),
295 ));
296 }
297 }
298 "result" | "error" => {
299 if self.terminal_emitted {
300 return Err(CliEmitterError::Lifecycle(
301 "cannot emit duplicate terminal event".to_string(),
302 ));
303 }
304 }
305 _ => {
306 return Err(CliEmitterError::Validation(ProtocolViolation {
307 rule: "kind_unsupported",
308 pointer: "/kind".to_string(),
309 message: format!("unsupported event kind {kind:?}"),
310 }));
311 }
312 }
313 let rendered = crate::formatting::render(&event, self.format, &self.output_options);
314 self.writer.write_all(rendered.as_bytes())?;
315 self.writer.write_all(b"\n")?;
316 self.writer.flush()?;
317 if matches!(kind, "result" | "error") {
318 self.terminal_emitted = true;
319 }
320 Ok(())
321 }
322}
323
324pub fn build_cli_version(version: &str) -> Event {
327 json_result(serde_json::json!({ "code": "version", "version": version })).build()
328}
329
330pub fn cli_render_version(name: &str, version: &str, format: Option<OutputFormat>) -> String {
335 let mut rendered = match format {
336 Some(format) => crate::formatting::render(
337 build_cli_version(version).as_value(),
338 format,
339 &OutputOptions::default(),
340 ),
341 None => format!("{name} {version}"),
342 };
343 while rendered.ends_with('\n') {
344 rendered.pop();
345 }
346 rendered.push('\n');
347 rendered
348}
349
350pub fn cli_handle_version_or_continue(
368 raw_args: &[String],
369 name: &str,
370 version: &str,
371) -> Result<Option<String>, Event> {
372 let parsed = parse_version_request(raw_args);
373 if !parsed.version_requested {
374 return Ok(None);
375 }
376 if let Some(error) = parsed.output_error {
377 let event = build_cli_error(
378 &error,
379 Some("valid version output formats: json, yaml, plain"),
380 );
381 return Err(event);
382 }
383 Ok(Some(cli_render_version(
384 name,
385 version,
386 parsed.output_format,
387 )))
388}
389
390struct ParsedVersionRequest {
391 version_requested: bool,
392 output_format: Option<OutputFormat>,
393 output_error: Option<String>,
394}
395
396fn parse_version_request(raw_args: &[String]) -> ParsedVersionRequest {
397 let args = raw_args.get(1..).unwrap_or(&[]);
398 let mut version_requested = false;
399 let mut output_format = None;
400 let mut output_error = None;
401
402 let mut i = 0usize;
403 while i < args.len() {
404 let arg = args[i].as_str();
405 if arg == "--" {
406 break;
407 }
408 if !arg.starts_with('-') {
412 break;
413 }
414
415 let (flag_name, inline_value) = split_flag(arg);
416 if matches!(arg, "--version" | "-V") {
417 version_requested = true;
418 i += 1;
419 continue;
420 }
421
422 if arg == "--json" {
423 set_version_output_format(
424 &mut output_format,
425 OutputFormat::Json,
426 "--json",
427 &mut output_error,
428 );
429 i += 1;
430 continue;
431 }
432
433 if flag_name == Some("output") {
434 let value = inline_value.or_else(|| {
435 args.get(i + 1)
436 .map(String::as_str)
437 .filter(|next| !next.starts_with('-'))
438 });
439 if let Some(value) = value {
440 match cli_parse_output(value) {
441 Ok(format) => set_version_output_format(
442 &mut output_format,
443 format,
444 &format!("--output {value}"),
445 &mut output_error,
446 ),
447 Err(err) => output_error = Some(err),
448 }
449 } else {
450 output_error =
451 Some("missing value for --output: expected json, yaml, or plain".to_string());
452 }
453 i += if inline_value.is_some() || value.is_none() {
454 1
455 } else {
456 2
457 };
458 continue;
459 }
460 i += 1;
461 }
462
463 ParsedVersionRequest {
464 version_requested,
465 output_format,
466 output_error,
467 }
468}
469
470fn set_version_output_format(
471 current: &mut Option<OutputFormat>,
472 next: OutputFormat,
473 source: &str,
474 output_error: &mut Option<String>,
475) {
476 if let Some(existing) = current
477 && *existing != next
478 {
479 *output_error = Some(format!(
480 "conflicting output formats: {source} conflicts with previous output format"
481 ));
482 return;
483 }
484 *current = Some(next);
485}
486
487fn split_flag(arg: &str) -> (Option<&str>, Option<&str>) {
488 if !arg.starts_with('-') || arg == "-" {
489 return (None, None);
490 }
491 let (flag, value) = arg.split_once('=').unwrap_or((arg, ""));
492 let name = flag.trim_start_matches('-');
493 if name.is_empty() {
494 (None, None)
495 } else if arg.contains('=') {
496 (Some(name), Some(value))
497 } else {
498 (Some(name), None)
499 }
500}