use serde::Serialize;
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputMode {
Human,
Json,
Quiet,
}
#[derive(Debug, Clone, Copy)]
pub enum ExitCode {
Success = 0,
GeneralError = 1,
PermissionDenied = 2,
NotFound = 3,
StateConflict = 4,
DependencyMissing = 5,
Timeout = 6,
}
impl ExitCode {
#[must_use]
pub fn code(self) -> i32 {
self as i32
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CliError {
pub code: &'static str,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CliResponse<T: Serialize> {
pub schema_version: u32,
pub ok: bool,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<CliError>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_actions: Option<Vec<String>>,
}
impl<T: Serialize> CliResponse<T> {
pub fn success(command: &str, data: T, next_actions: Vec<String>) -> Self {
Self {
schema_version: SCHEMA_VERSION,
ok: true,
command: command.to_string(),
data: Some(data),
error: None,
next_actions: if next_actions.is_empty() {
None
} else {
Some(next_actions)
},
}
}
}
#[must_use]
pub fn error_response(command: &str, err: CliError) -> CliResponse<()> {
CliResponse {
schema_version: SCHEMA_VERSION,
ok: false,
command: command.to_string(),
data: None,
error: Some(err),
next_actions: None,
}
}
pub fn print_success<T: Serialize>(
mode: OutputMode,
command: &str,
data: &T,
next_actions: Vec<String>,
) {
match mode {
OutputMode::Json => {
let resp = CliResponse {
schema_version: SCHEMA_VERSION,
ok: true,
command: command.to_string(),
data: Some(data),
error: None::<CliError>,
next_actions: if next_actions.is_empty() {
None
} else {
Some(next_actions)
},
};
println!(
"{}",
serde_json::to_string_pretty(&resp).unwrap_or_else(|_| "{}".into())
);
}
OutputMode::Quiet => {}
OutputMode::Human => {
println!(
"{}",
serde_json::to_string_pretty(data).unwrap_or_else(|_| "{}".into())
);
}
}
}
pub fn print_error_and_exit(mode: OutputMode, command: &str, err: CliError, exit: ExitCode) -> ! {
match mode {
OutputMode::Json => {
let resp = error_response(command, err);
println!(
"{}",
serde_json::to_string_pretty(&resp).unwrap_or_else(|_| "{}".into())
);
}
OutputMode::Quiet => {
eprintln!("error: {}", err.message);
}
OutputMode::Human => {
eprintln!("error: {}", err.message);
if let Some(hint) = &err.hint {
eprintln!(" hint: {hint}");
}
}
}
std::process::exit(exit.code());
}
#[must_use]
pub fn err_permission_denied(fix_command: &str) -> CliError {
CliError {
code: "permission_denied",
message: "VPN operations require root privileges".into(),
hint: Some(format!("Re-run with: sudo {fix_command}")),
}
}
#[must_use]
pub fn err_not_found(profile: &str) -> CliError {
CliError {
code: "not_found",
message: format!("Profile '{profile}' not found"),
hint: Some("Run 'vortix list' to see available profiles".into()),
}
}
#[must_use]
pub fn err_dependency_missing(deps: &[String]) -> CliError {
CliError {
code: "dependency_missing",
message: format!("Missing dependencies: {}", deps.join(", ")),
hint: Some(
deps.iter()
.map(|d| format!("Install: {}", crate::platform::install_hint(d)))
.collect::<Vec<_>>()
.join("; "),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_version_is_pinned_to_1() {
assert_eq!(SCHEMA_VERSION, 1);
}
#[test]
fn cli_response_success_serializes_with_schema_version() {
#[derive(Serialize)]
struct Payload {
name: &'static str,
}
let resp = CliResponse::success("test", Payload { name: "x" }, vec![]);
let json = serde_json::to_string(&resp).unwrap();
assert!(
json.contains("\"schema_version\":1"),
"missing schema_version in: {json}"
);
assert!(json.contains("\"ok\":true"));
assert!(json.contains("\"command\":\"test\""));
}
#[test]
fn error_response_serializes_with_schema_version() {
let err = CliError {
code: "test_error",
message: "boom".into(),
hint: None,
};
let resp = error_response("test", err);
let json = serde_json::to_string(&resp).unwrap();
assert!(
json.contains("\"schema_version\":1"),
"missing schema_version in: {json}"
);
assert!(json.contains("\"ok\":false"));
}
}