use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::io::IsTerminal;
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
pub const CLI_SCHEMA: &str = "void.cli/v1";
#[derive(Debug, Clone, Default)]
pub struct CliOptions {
pub json: bool,
pub human: bool,
pub quiet: bool,
pub debug: bool,
pub verbose: bool,
}
impl CliOptions {
pub fn is_json_mode(&self) -> bool {
if self.human {
return false;
}
if self.json {
return true;
}
!std::io::stdout().is_terminal()
}
pub fn is_human_mode(&self) -> bool {
!self.is_json_mode()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ErrorCode {
InvalidArgs,
NotInitialized,
NotFound,
Conflict,
IoError,
EncryptionError,
NotImplemented,
Internal,
}
impl ErrorCode {
pub fn exit_code(self) -> i32 {
match self {
ErrorCode::InvalidArgs => 2,
ErrorCode::NotInitialized => 4,
ErrorCode::NotFound => 5,
ErrorCode::Conflict => 6,
ErrorCode::IoError => 1,
ErrorCode::EncryptionError => 1,
ErrorCode::NotImplemented => 3,
ErrorCode::Internal => 1,
}
}
}
impl fmt::Display for ErrorCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ErrorCode::InvalidArgs => write!(f, "INVALID_ARGS"),
ErrorCode::NotInitialized => write!(f, "NOT_INITIALIZED"),
ErrorCode::NotFound => write!(f, "NOT_FOUND"),
ErrorCode::Conflict => write!(f, "CONFLICT"),
ErrorCode::IoError => write!(f, "IO_ERROR"),
ErrorCode::EncryptionError => write!(f, "ENCRYPTION_ERROR"),
ErrorCode::NotImplemented => write!(f, "NOT_IMPLEMENTED"),
ErrorCode::Internal => write!(f, "INTERNAL"),
}
}
}
#[derive(Debug, Error, Clone, Serialize, Deserialize)]
pub struct CliError {
pub code: ErrorCode,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<HashMap<String, serde_json::Value>>,
}
impl fmt::Display for CliError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}: {}", self.code, self.message)?;
if let Some(details) = &self.details {
if !details.is_empty() {
write!(f, " ({:?})", details)?;
}
}
Ok(())
}
}
impl CliError {
pub fn new(code: ErrorCode, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
details: None,
}
}
pub fn with_details(
code: ErrorCode,
message: impl Into<String>,
details: HashMap<String, serde_json::Value>,
) -> Self {
Self {
code,
message: message.into(),
details: Some(details),
}
}
pub fn exit_code(&self) -> i32 {
self.code.exit_code()
}
pub fn invalid_args(message: impl Into<String>) -> Self {
Self::new(ErrorCode::InvalidArgs, message)
}
pub fn not_initialized(message: impl Into<String>) -> Self {
Self::new(ErrorCode::NotInitialized, message)
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new(ErrorCode::NotFound, message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Conflict, message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new(ErrorCode::Internal, message)
}
pub fn io_error(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::IoError, msg)
}
pub fn encryption_error(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::EncryptionError, msg)
}
#[allow(dead_code)]
pub fn not_implemented(msg: impl Into<String>) -> Self {
Self::new(ErrorCode::NotImplemented, msg)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
#[serde(rename = "type")]
pub entry_type: LogEntryType,
pub message: String,
pub ts: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogEntryType {
Progress,
Warn,
Info,
Error,
}
impl LogEntry {
pub fn new(entry_type: LogEntryType, message: impl Into<String>) -> Self {
Self {
entry_type,
message: message.into(),
ts: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0),
}
}
pub fn progress(message: impl Into<String>) -> Self {
Self::new(LogEntryType::Progress, message)
}
pub fn warn(message: impl Into<String>) -> Self {
Self::new(LogEntryType::Warn, message)
}
pub fn info(message: impl Into<String>) -> Self {
Self::new(LogEntryType::Info, message)
}
#[allow(dead_code)]
pub fn error(message: impl Into<String>) -> Self {
Self::new(LogEntryType::Error, message)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuccessEnvelope<T> {
pub schema: String,
pub ok: bool,
pub command: String,
pub result: T,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErrorEnvelope {
pub schema: String,
pub ok: bool,
pub command: String,
pub error: CliError,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub log: Vec<LogEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResultWithLog<T> {
#[serde(flatten)]
pub data: T,
pub log: Vec<LogEntry>,
}
#[allow(dead_code)]
impl<T> ResultWithLog<T> {
pub fn new(data: T) -> Self {
Self {
data,
log: Vec::new(),
}
}
pub fn with_log(data: T, log: Vec<LogEntry>) -> Self {
Self { data, log }
}
}
fn get_terminal_width() -> usize {
terminal_size::terminal_size()
.map(|(w, _)| w.0 as usize)
.unwrap_or(80)
}
fn compact_stringify<T: Serialize>(value: &T) -> String {
let compact = serde_json::to_string(value).unwrap_or_default();
let terminal_width = get_terminal_width();
if compact.len() <= terminal_width {
return compact;
}
serde_json::to_string_pretty(value).unwrap_or(compact)
}
mod json_colors {
pub const CYAN: &str = "\x1b[36m";
pub const GREEN: &str = "\x1b[32m";
pub const MAGENTA: &str = "\x1b[35m";
pub const YELLOW: &str = "\x1b[33m";
pub const DIM: &str = "\x1b[2m";
pub const RESET: &str = "\x1b[0m";
}
fn colorize_json(json: &str) -> String {
let mut result = String::with_capacity(json.len() * 2);
let mut chars = json.chars().peekable();
let mut in_string = false;
let mut after_colon = false;
while let Some(c) = chars.next() {
match c {
'\\' if in_string => {
result.push(c);
if let Some(escaped) = chars.next() {
result.push(escaped);
}
}
'"' if !in_string => {
in_string = true;
let mut lookahead = String::new();
let mut temp_chars = chars.clone();
while let Some(&next) = temp_chars.peek() {
if next == '"' {
temp_chars.next();
break;
}
lookahead.push(next);
temp_chars.next();
}
while let Some(&next) = temp_chars.peek() {
if next.is_whitespace() {
temp_chars.next();
} else {
break;
}
}
let is_key = temp_chars.peek() == Some(&':');
if after_colon {
result.push_str(json_colors::GREEN);
after_colon = false;
} else if is_key {
result.push_str(json_colors::CYAN);
} else {
result.push_str(json_colors::GREEN);
}
result.push(c);
}
'"' if in_string => {
result.push(c);
result.push_str(json_colors::RESET);
in_string = false;
}
':' if !in_string => {
result.push(c);
after_colon = true;
}
'0'..='9' | '-' | '.' if !in_string && after_colon => {
result.push_str(json_colors::MAGENTA);
result.push(c);
while let Some(&next) = chars.peek() {
if next.is_ascii_digit()
|| next == '.'
|| next == 'e'
|| next == 'E'
|| next == '+'
|| next == '-'
{
result.push(chars.next().unwrap());
} else {
break;
}
}
result.push_str(json_colors::RESET);
after_colon = false;
}
't' if !in_string => {
if chars.clone().take(3).collect::<String>() == "rue" {
result.push_str(json_colors::YELLOW);
result.push_str("true");
for _ in 0..3 {
chars.next();
}
result.push_str(json_colors::RESET);
after_colon = false;
} else {
result.push(c);
}
}
'f' if !in_string => {
if chars.clone().take(4).collect::<String>() == "alse" {
result.push_str(json_colors::YELLOW);
result.push_str("false");
for _ in 0..4 {
chars.next();
}
result.push_str(json_colors::RESET);
after_colon = false;
} else {
result.push(c);
}
}
'n' if !in_string => {
if chars.clone().take(3).collect::<String>() == "ull" {
result.push_str(json_colors::DIM);
result.push_str("null");
for _ in 0..3 {
chars.next();
}
result.push_str(json_colors::RESET);
after_colon = false;
} else {
result.push(c);
}
}
',' | '{' | '}' | '[' | ']' if !in_string => {
result.push(c);
if c == ',' {
after_colon = false;
}
}
_ => {
result.push(c);
}
}
}
result
}
pub fn output_success<T: Serialize>(command: &str, result: T) {
let envelope = SuccessEnvelope {
schema: CLI_SCHEMA.to_string(),
ok: true,
command: command.to_string(),
result,
};
let json = compact_stringify(&envelope);
if std::io::stdout().is_terminal() {
println!("{}", colorize_json(&json));
} else {
println!("{}", json);
}
}
pub fn output_error(command: &str, error: &CliError, log: Vec<LogEntry>, opts: &CliOptions) {
let envelope = ErrorEnvelope {
schema: CLI_SCHEMA.to_string(),
ok: false,
command: command.to_string(),
error: error.clone(),
log,
};
let json = compact_stringify(&envelope);
if std::io::stderr().is_terminal() {
eprintln!("{}", colorize_json(&json));
} else {
eprintln!("{}", json);
}
if opts.debug && !std::io::stderr().is_terminal() {
if let Some(details) = &error.details {
eprintln!("Debug details: {:?}", details);
}
}
}
#[allow(dead_code)]
pub struct CommandContext {
command: String,
opts: CliOptions,
log: Vec<LogEntry>,
prefer_human: bool,
}
#[allow(dead_code)]
impl CommandContext {
pub fn new(command: impl Into<String>, opts: CliOptions) -> Self {
Self {
command: command.into(),
opts,
log: Vec::new(),
prefer_human: false,
}
}
pub fn set_prefer_human(&mut self) {
self.prefer_human = true;
}
pub fn progress(&mut self, message: impl Into<String>) {
let msg = message.into();
self.log.push(LogEntry::progress(&msg));
if self.opts.is_human_mode() && !self.opts.quiet {
eprintln!("{}", msg);
}
}
pub fn warn(&mut self, message: impl Into<String>) {
let msg = message.into();
self.log.push(LogEntry::warn(&msg));
if self.opts.is_human_mode() && !self.opts.quiet {
eprintln!("warning: {}", msg);
}
}
pub fn info(&mut self, message: impl Into<String>) {
let msg = message.into();
self.log.push(LogEntry::info(&msg));
if self.opts.is_human_mode() && !self.opts.quiet {
eprintln!("{}", msg);
}
}
pub fn error(&mut self, message: impl Into<String>) {
let msg = message.into();
self.log.push(LogEntry::error(&msg));
if self.opts.is_human_mode() && !self.opts.quiet {
eprintln!("error: {}", msg);
}
}
pub fn verbose(&mut self, message: impl Into<String>) {
if self.opts.verbose {
let msg = message.into();
self.log.push(LogEntry::info(&msg));
if self.opts.is_human_mode() && !self.opts.quiet {
eprintln!("[verbose] {}", msg);
}
}
}
pub fn debug(&mut self, message: impl Into<String>) {
if self.opts.debug {
let msg = message.into();
self.log.push(LogEntry::info(&msg));
if self.opts.is_human_mode() && !self.opts.quiet {
eprintln!("[debug] {}", msg);
}
}
}
pub fn use_json(&self) -> bool {
if self.prefer_human {
self.opts.json && !self.opts.human
} else {
self.opts.is_json_mode()
}
}
pub fn is_verbose(&self) -> bool {
self.opts.verbose
}
pub fn is_debug(&self) -> bool {
self.opts.debug
}
pub fn is_quiet(&self) -> bool {
self.opts.quiet
}
pub fn command(&self) -> &str {
&self.command
}
pub fn opts(&self) -> &CliOptions {
&self.opts
}
pub fn into_log(self) -> Vec<LogEntry> {
self.log
}
}
pub fn run_command<T, F>(command: &str, opts: &CliOptions, f: F) -> Result<(), CliError>
where
T: Serialize,
F: FnOnce(&mut CommandContext) -> Result<T, CliError>,
{
let mut ctx = CommandContext::new(command, opts.clone());
match f(&mut ctx) {
Ok(result) => {
let json_mode = ctx.use_json();
if json_mode {
let result_with_log = ResultWithLog::with_log(result, ctx.into_log());
output_success(command, result_with_log);
}
Ok(())
}
Err(error) => {
if ctx.use_json() {
output_error(command, &error, ctx.into_log(), opts);
} else {
eprintln!("error: {}", error);
if opts.debug {
if let Some(details) = &error.details {
eprintln!("\nDebug details: {:?}", details);
}
}
}
Err(error)
}
}
}
#[allow(dead_code)]
pub trait IntoCliError {
fn into_cli_error(self) -> CliError;
}
impl IntoCliError for std::io::Error {
fn into_cli_error(self) -> CliError {
CliError::internal(self.to_string())
}
}
impl IntoCliError for serde_json::Error {
fn into_cli_error(self) -> CliError {
CliError::internal(format!("JSON error: {}", self))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Serialize;
#[derive(Serialize)]
struct TestResult {
value: i32,
}
#[test]
fn test_error_codes() {
assert_eq!(ErrorCode::InvalidArgs.exit_code(), 2);
assert_eq!(ErrorCode::NotInitialized.exit_code(), 4);
assert_eq!(ErrorCode::NotFound.exit_code(), 5);
assert_eq!(ErrorCode::Conflict.exit_code(), 6);
assert_eq!(ErrorCode::Internal.exit_code(), 1);
}
#[test]
fn test_cli_error_display() {
let err = CliError::invalid_args("missing path argument");
assert!(err.to_string().contains("INVALID_ARGS"));
assert!(err.to_string().contains("missing path argument"));
let mut details = HashMap::new();
details.insert(
"path".to_string(),
serde_json::Value::String("path/to/file.txt".to_string()),
);
let err_with_details =
CliError::with_details(ErrorCode::NotFound, "file not found", details);
assert!(err_with_details.to_string().contains("path/to/file.txt"));
}
#[test]
fn test_log_entry_types() {
let progress = LogEntry::progress("Loading...");
assert_eq!(progress.entry_type, LogEntryType::Progress);
let warn = LogEntry::warn("Deprecated feature");
assert_eq!(warn.entry_type, LogEntryType::Warn);
let info = LogEntry::info("Done");
assert_eq!(info.entry_type, LogEntryType::Info);
let error = LogEntry::error("Something failed");
assert_eq!(error.entry_type, LogEntryType::Error);
}
#[test]
fn test_success_envelope_serialization() {
let envelope = SuccessEnvelope {
schema: CLI_SCHEMA.to_string(),
ok: true,
command: "test".to_string(),
result: TestResult { value: 42 },
};
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("\"schema\":\"void.cli/v1\""));
assert!(json.contains("\"ok\":true"));
assert!(json.contains("\"command\":\"test\""));
assert!(json.contains("\"value\":42"));
}
#[test]
fn test_error_envelope_serialization() {
let envelope = ErrorEnvelope {
schema: CLI_SCHEMA.to_string(),
ok: false,
command: "test".to_string(),
error: CliError::not_found("resource missing"),
log: vec![],
};
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("\"ok\":false"));
assert!(json.contains("\"code\":\"NOT_FOUND\""));
assert!(!json.contains("\"log\""));
}
#[test]
fn test_error_envelope_with_log() {
let envelope = ErrorEnvelope {
schema: CLI_SCHEMA.to_string(),
ok: false,
command: "test".to_string(),
error: CliError::not_found("resource missing"),
log: vec![LogEntry::progress("Starting..."), LogEntry::info("Step 1")],
};
let json = serde_json::to_string(&envelope).unwrap();
assert!(json.contains("\"ok\":false"));
assert!(json.contains("\"code\":\"NOT_FOUND\""));
assert!(json.contains("\"log\""));
assert!(json.contains("\"type\":\"progress\""));
assert!(json.contains("\"type\":\"info\""));
}
#[test]
fn test_result_with_log_always_has_log() {
let result = ResultWithLog::new(TestResult { value: 1 });
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"value\":1"));
assert!(json.contains("\"log\":[]"));
}
#[test]
fn test_result_with_log_entries() {
let result =
ResultWithLog::with_log(TestResult { value: 1 }, vec![LogEntry::progress("test")]);
let json = serde_json::to_string(&result).unwrap();
assert!(json.contains("\"value\":1"));
assert!(json.contains("\"log\""));
assert!(json.contains("\"type\":\"progress\""));
}
#[test]
fn test_cli_options_mode_priority() {
let opts = CliOptions {
human: true,
json: true,
..Default::default()
};
assert!(!opts.is_json_mode());
assert!(opts.is_human_mode());
let opts = CliOptions {
json: true,
..Default::default()
};
assert!(opts.is_json_mode());
let opts = CliOptions::default();
}
#[test]
fn test_compact_stringify_short() {
let data = TestResult { value: 42 };
let json = compact_stringify(&data);
assert!(!json.contains('\n'));
assert!(json.contains("\"value\":42"));
}
#[test]
fn test_colorize_json() {
let json = r#"{"key":"value","num":42,"bool":true,"nil":null}"#;
let colored = colorize_json(json);
assert!(colored.contains("\x1b["));
assert!(colored.contains("key"));
assert!(colored.contains("value"));
assert!(colored.contains("42"));
assert!(colored.contains("true"));
assert!(colored.contains("null"));
}
#[test]
fn test_colorize_json_escaped_quotes() {
let json = r#"{"msg":"say \"hello\" world"}"#;
let colored = colorize_json(json);
let reset_count = colored.matches(json_colors::RESET).count();
assert_eq!(reset_count, 2, "escaped quotes should not produce extra RESETs");
assert!(colored.contains(r#"\"hello\""#));
}
#[test]
fn test_error_log_entry_type() {
let error = LogEntry::error("Test error");
let json = serde_json::to_string(&error).unwrap();
assert!(json.contains("\"type\":\"error\""));
}
}