use crate::error::CliError;
use crate::render::style::{self, Style};
use crate::signals::SignalState;
use serde::Serialize;
use std::borrow::Cow;
use std::io::{self, Write};
use std::sync::Arc;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Format {
Pretty,
Table,
Json,
}
impl Format {
pub const ALL: [Format; 3] = [Format::Pretty, Format::Table, Format::Json];
pub fn name(self) -> &'static str {
match self {
Self::Pretty => "pretty",
Self::Table => "table",
Self::Json => "json",
}
}
pub fn parse(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|format| format.name() == name)
}
pub fn valid_names() -> String {
Self::ALL.map(Self::name).join(", ")
}
}
pub fn terminal_safe(text: &str) -> Cow<'_, str> {
let control = |c: char| c.is_control();
if text.chars().any(control) {
Cow::Owned(
text.chars()
.map(|c| if control(c) { '\u{fffd}' } else { c })
.collect(),
)
} else {
Cow::Borrowed(text)
}
}
pub fn terminal_safe_lines(text: &str) -> Cow<'_, str> {
let mut chars = text.chars().peekable();
let mut kept = true;
let mut out = String::with_capacity(text.len());
while let Some(c) = chars.next() {
let keep =
!c.is_control() || c == '\n' || c == '\t' || (c == '\r' && chars.peek() == Some(&'\n'));
kept &= keep;
out.push(if keep { c } else { '\u{fffd}' });
}
if kept {
Cow::Borrowed(text)
} else {
Cow::Owned(out)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FormatPolicy {
Renders { tenant_default: bool },
Ignored,
NoData,
Csv,
Report,
}
pub const RENDERS: FormatPolicy = FormatPolicy::Renders {
tenant_default: false,
};
pub const TENANT_DEFAULT: FormatPolicy = FormatPolicy::Renders {
tenant_default: true,
};
pub fn validate_explicit(explicit: Option<&str>) -> Result<Option<Format>, CliError> {
let Some(name) = explicit else {
return Ok(None);
};
Format::parse(name).map(Some).ok_or_else(|| {
CliError::usage(format!(
"Invalid output format '{name}'. Expected: {}",
Format::valid_names()
))
})
}
pub fn resolve(explicit: Option<&str>) -> Result<Format, CliError> {
Ok(validate_explicit(explicit)?.unwrap_or(Format::Pretty))
}
pub fn resolve_tenant_default(explicit: Option<Format>, configured: Option<&str>) -> Format {
explicit
.or_else(|| configured.and_then(Format::parse))
.unwrap_or(Format::Pretty)
}
pub struct Output {
quiet: bool,
color_stdout: bool,
color_stderr: bool,
buffer: Vec<u8>,
stdout: Box<dyn Write>,
stderr: Box<dyn Write>,
signals: Option<Arc<SignalState>>,
debug_hint: bool,
}
impl Output {
pub fn stdio(quiet: bool) -> Self {
Self::with_sinks(quiet, Box::new(io::stdout()), Box::new(io::stderr()))
.with_colors(style::stdout_color(), style::stderr_color())
}
pub fn with_sinks(quiet: bool, stdout: Box<dyn Write>, stderr: Box<dyn Write>) -> Self {
Self {
quiet,
color_stdout: false,
color_stderr: false,
buffer: Vec::new(),
stdout,
stderr,
signals: None,
debug_hint: true,
}
}
pub fn set_debug(&mut self, debug: bool) {
self.debug_hint = !debug;
}
pub fn with_colors(mut self, stdout: bool, stderr: bool) -> Self {
self.color_stdout = stdout;
self.color_stderr = stderr;
self
}
pub fn paint(&self, style: Style, text: &str) -> String {
style::paint(style, text, self.color_stdout)
}
pub fn paint_err(&self, style: Style, text: &str) -> String {
style::paint(style, text, self.color_stderr)
}
pub fn report(&mut self, error: &CliError) {
let text = error.render_styled(self.color_stderr, self.debug_hint);
self.write_stderr(&text);
}
pub fn attach_signals(&mut self, signals: Arc<SignalState>) {
self.signals = Some(signals);
}
pub fn pending_signal(&self) -> Option<i32> {
self.signals.as_ref().and_then(|signals| signals.code())
}
pub fn signals(&self) -> Option<Arc<SignalState>> {
self.signals.clone()
}
pub fn quiet(&self) -> bool {
self.quiet
}
pub fn success(&mut self, message: &str) {
self.outcome(Style::Green, '✓', message);
}
pub fn info(&mut self, message: &str) {
self.outcome(Style::Cyan, 'ℹ', message);
}
fn outcome(&mut self, style: Style, glyph: char, message: &str) {
if self.quiet || self.pending_signal().is_some() {
return;
}
let text = self.paint_err(style, &format!("{glyph} {}", terminal_safe(message)));
self.write_stderr(&format!(" {text}\n"));
}
pub fn warning(&mut self, warning: &str) {
let text = self.paint_err(Style::Yellow, warning);
self.write_stderr(&format!(" {text}\n"));
}
pub fn status(&mut self, message: &str) {
if !self.quiet {
let message = self.paint_err(Style::Dim, message);
self.write_stderr(&format!(" {message}\n"));
}
}
pub fn write_stderr(&mut self, text: &str) {
let _ = self.stderr.write_all(text.as_bytes());
let _ = self.stderr.flush();
}
pub fn data(&mut self, text: &str) {
self.buffer.extend_from_slice(text.as_bytes());
}
pub fn data_line(&mut self, text: &str) {
self.data(text);
self.data("\n");
}
pub fn decoration(&mut self, text: &str) {
if !self.quiet {
self.data(text);
}
}
pub fn json<T: Serialize + ?Sized>(&mut self, value: &T) -> Result<(), CliError> {
let text = crate::json::to_pretty(value).map_err(|_| {
CliError::operational("AISEC_SDK_ERROR:Output could not be serialized as JSON")
})?;
self.data_line(&text);
Ok(())
}
pub fn commit(&mut self) -> io::Result<()> {
if self.buffer.is_empty() {
return Ok(());
}
let _writing = self.signals.as_deref().map(SignalState::begin_write);
if self.pending_signal().is_some() {
self.buffer.clear();
return Err(io::Error::from(io::ErrorKind::Interrupted));
}
let buffer = std::mem::take(&mut self.buffer);
self.stdout.write_all(&buffer)?;
self.stdout.flush()
}
pub fn discard(&mut self) {
self.buffer.clear();
}
}
#[cfg(test)]
pub(crate) mod testing {
use super::Output;
use std::cell::RefCell;
use std::io::{self, Write};
use std::rc::Rc;
#[derive(Clone, Default)]
pub struct Capture(pub Rc<RefCell<Vec<u8>>>);
impl Capture {
pub fn text(&self) -> String {
String::from_utf8(self.0.borrow().clone()).unwrap()
}
}
impl Write for Capture {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0.borrow_mut().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
pub fn capture(quiet: bool) -> (Output, Capture, Capture) {
let stdout = Capture::default();
let stderr = Capture::default();
let output = Output::with_sinks(quiet, Box::new(stdout.clone()), Box::new(stderr.clone()));
(output, stdout, stderr)
}
pub fn capture_colored(quiet: bool) -> (Output, Capture, Capture) {
let (output, stdout, stderr) = capture(quiet);
(output.with_colors(true, true), stdout, stderr)
}
}
#[cfg(test)]
mod tests {
use super::testing::capture;
use super::*;
#[test]
fn explicit_formats_are_validated() {
assert_eq!(resolve(None).unwrap(), Format::Pretty);
assert_eq!(resolve(Some("json")).unwrap(), Format::Json);
assert_eq!(resolve(Some("table")).unwrap(), Format::Table);
for dead in ["xml", "markdown", "csv", "yaml", "JSON", ""] {
assert_eq!(
resolve(Some(dead)),
Err(CliError::usage(format!(
"Invalid output format '{dead}'. Expected: pretty, table, json"
))),
"{dead}"
);
}
}
#[test]
fn tenant_default_matrix() {
assert_eq!(resolve_tenant_default(None, None), Format::Pretty);
assert_eq!(resolve_tenant_default(None, Some("table")), Format::Table);
assert_eq!(resolve_tenant_default(None, Some("json")), Format::Json);
assert_eq!(
resolve_tenant_default(Some(Format::Pretty), Some("table")),
Format::Pretty,
"an explicit --output wins"
);
}
#[test]
fn status_lines_are_dim_on_a_coloured_stderr() {
let (mut output, stdout, stderr) = testing::capture_colored(false);
output.status("Showing 2");
output.report(&CliError::usage("bad"));
output.commit().unwrap();
assert_eq!(stdout.text(), "");
assert_eq!(
stderr.text(),
" \u{1b}[2mShowing 2\u{1b}[22m\n\u{1b}[31m\u{1b}[39m\n\u{1b}[31m ✗ bad\u{1b}[39m\n\u{1b}[31m\u{1b}[39m\n"
);
let (mut output, _, stderr) = testing::capture_colored(false);
assert_eq!(output.paint(Style::Bold, "x"), "\u{1b}[1mx\u{1b}[22m");
output.success("Selected dev (TSG 100)");
output.info("Aborted");
output.warning("⚠ x was saved");
assert_eq!(
stderr.text(),
" \u{1b}[32m✓ Selected dev (TSG 100)\u{1b}[39m\n \u{1b}[36mℹ Aborted\u{1b}[39m\n \u{1b}[33m⚠ x was saved\u{1b}[39m\n"
);
let (mut quiet, _, stderr) = testing::capture(true);
quiet.success("hidden");
quiet.info("hidden");
quiet.warning("⚠ shown");
assert_eq!(
stderr.text(),
" ⚠ shown\n",
"only the warning survives --quiet"
);
let plain = Output::with_sinks(false, Box::new(io::sink()), Box::new(io::sink()))
.with_colors(false, true);
assert_eq!(
plain.paint(Style::Bold, "x"),
"x",
"stdout decided on its own"
);
assert_eq!(plain.paint_err(Style::Bold, "x"), "\u{1b}[1mx\u{1b}[22m");
}
#[test]
fn status_and_decoration_respect_quiet() {
let (mut output, stdout, stderr) = capture(true);
output.status("hidden");
output.decoration("hidden\n");
output.data_line("data");
output.write_stderr("err\n");
output.commit().unwrap();
assert_eq!(stdout.text(), "data\n");
assert_eq!(stderr.text(), "err\n");
let (mut output, stdout, stderr) = capture(false);
output.status("Showing 1");
output.decoration("\n Heading\n\n");
output.json(&[1.0_f64]).unwrap();
assert_eq!(stdout.text(), "", "stdout is buffered until commit");
output.commit().unwrap();
assert_eq!(stdout.text(), "\n Heading\n\n[\n 1\n]\n");
assert_eq!(stderr.text(), " Showing 1\n");
}
#[test]
fn control_characters_are_replaced() {
assert_eq!(terminal_safe("plain é ✓"), "plain é ✓");
assert!(matches!(terminal_safe("plain"), Cow::Borrowed(_)));
assert_eq!(
terminal_safe("R\u{1b}]0;pwned\u{7}\u{9b}2K\u{7f}\n\t"),
"R\u{fffd}]0;pwned\u{fffd}\u{fffd}2K\u{fffd}\u{fffd}\u{fffd}"
);
}
#[test]
fn multi_line_text_keeps_line_breaks_and_tabs_only() {
let summary = "### Summary\n\n- one\r\n- two\twide";
assert_eq!(terminal_safe_lines(summary), summary);
assert!(matches!(terminal_safe_lines(summary), Cow::Borrowed(_)));
assert_eq!(
terminal_safe_lines("a\u{1b}[31mb\rc\u{7}d\u{7f}e\u{9b}2Kf\u{0}\u{b}\u{c}\n\r"),
"a\u{fffd}[31mb\u{fffd}c\u{fffd}d\u{fffd}e\u{fffd}2Kf\u{fffd}\u{fffd}\u{fffd}\n\u{fffd}"
);
assert_eq!(terminal_safe_lines("\r\r\n"), "\u{fffd}\r\n");
}
#[test]
fn discard_drops_buffered_data() {
let (mut output, stdout, _) = capture(false);
output.data_line("partial");
output.discard();
output.commit().unwrap();
assert_eq!(stdout.text(), "");
}
}