use std::sync::OnceLock;
use colored::Colorize;
use serde::Serialize;
use crate::errors::RailwayError;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum OutputMode {
Human,
Json,
}
static MODE: OnceLock<OutputMode> = OnceLock::new();
pub fn set_mode(json: bool) {
let _ = MODE.set(if json {
OutputMode::Json
} else {
OutputMode::Human
});
}
pub fn mode() -> OutputMode {
MODE.get().copied().unwrap_or(OutputMode::Human)
}
#[allow(dead_code)]
pub fn emit_json<T: Serialize>(value: &T) -> anyhow::Result<()> {
println!("{}", serde_json::to_string(value)?);
Ok(())
}
pub fn warn(code: &str, message: impl std::fmt::Display, hint: Option<&str>) {
match mode() {
OutputMode::Json => {
let obj = serde_json::json!({
"level": "warning",
"code": code,
"message": message.to_string(),
"hint": hint,
});
eprintln!("{obj}");
}
OutputMode::Human => {
eprintln!("{} {message}", "warning:".yellow().bold());
if let Some(hint) = hint {
eprintln!(" {} {hint}", "→".cyan());
}
}
}
}
enum Stream {
Stdout,
Stderr,
}
fn render_error_message(err: &anyhow::Error, mode: OutputMode) -> (Stream, String) {
match mode {
OutputMode::Json => {
let (code, hint) = match err.downcast_ref::<RailwayError>() {
Some(railway_err) => (railway_err.code(), railway_err.hint()),
None => ("ERROR", None),
};
let obj = serde_json::json!({
"error": err.to_string(),
"code": code,
"hint": hint,
});
(Stream::Stdout, obj.to_string())
}
OutputMode::Human => {
let mut text = format!("{err:?}");
if let Some(hint) = err
.downcast_ref::<RailwayError>()
.and_then(RailwayError::hint)
{
text.push_str(&format!("\n {} {hint}", "→".cyan()));
}
(Stream::Stderr, text)
}
}
}
pub fn render_error(err: &anyhow::Error) {
match render_error_message(err, mode()) {
(Stream::Stdout, text) => println!("{text}"),
(Stream::Stderr, text) => eprintln!("{text}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::RailwayError;
#[test]
fn human_error_surfaces_railway_hint() {
let err: anyhow::Error = RailwayError::NotAuthenticated.into();
let (stream, text) = render_error_message(&err, OutputMode::Human);
assert!(matches!(stream, Stream::Stderr));
assert!(text.contains("Not signed in."));
assert!(text.contains("railway login"));
}
#[test]
fn human_error_without_hint_is_just_the_message() {
let err: anyhow::Error = RailwayError::NoProjects.into();
let (stream, text) = render_error_message(&err, OutputMode::Human);
assert!(matches!(stream, Stream::Stderr));
assert!(!text.contains('→'));
}
#[test]
fn json_error_includes_code_and_hint_on_stdout() {
let err: anyhow::Error = RailwayError::NotAuthenticated.into();
let (stream, text) = render_error_message(&err, OutputMode::Json);
assert!(matches!(stream, Stream::Stdout));
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(v["code"], "NOT_AUTHENTICATED");
assert_eq!(v["error"], "Not signed in.");
assert!(v["hint"].as_str().unwrap().contains("railway login"));
}
#[test]
fn json_error_for_generic_anyhow_uses_error_bucket() {
let err = anyhow::anyhow!("boom");
let (_stream, text) = render_error_message(&err, OutputMode::Json);
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(v["code"], "ERROR");
assert_eq!(v["error"], "boom");
assert!(v["hint"].is_null());
}
}