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(())
}
static DEFERRED: std::sync::Mutex<Vec<(String, usize)>> = std::sync::Mutex::new(Vec::new());
const MAX_DEFERRED: usize = 20;
pub fn warn(code: &str, message: impl std::fmt::Display, hint: Option<&str>) {
let rendered = render_warning(code, message, hint, mode());
if crate::util::prompt::terminal_owned() {
defer(rendered);
return;
}
eprint!("{rendered}");
}
fn render_warning(
code: &str,
message: impl std::fmt::Display,
hint: Option<&str>,
mode: OutputMode,
) -> String {
match mode {
OutputMode::Json => {
let obj = serde_json::json!({
"level": "warning",
"code": code,
"message": message.to_string(),
"hint": hint,
});
format!("{obj}\n")
}
OutputMode::Human => {
let mut out = format!("{} {message}\n", "warning:".yellow().bold());
if let Some(hint) = hint {
out.push_str(&format!(" {} {hint}\n", "→".cyan()));
}
out
}
}
}
fn defer(rendered: String) {
let Ok(mut held) = DEFERRED.lock() else {
return;
};
if let Some(entry) = held.iter_mut().find(|(text, _)| *text == rendered) {
entry.1 += 1;
} else if held.len() < MAX_DEFERRED {
held.push((rendered, 1));
}
}
pub fn flush_deferred() {
let Ok(mut held) = DEFERRED.lock() else {
return;
};
for (rendered, count) in held.drain(..) {
eprint!("{rendered}");
if count > 1 {
eprintln!(
" {}",
format!("(repeated {count} times while the screen was open)").dimmed()
);
}
}
}
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;
static DEFERRED_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn deferred_warnings_are_deduplicated_and_counted() {
let _guard = DEFERRED_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
flush_deferred();
defer("lock timed out\n".to_string());
defer("lock timed out\n".to_string());
defer("lock timed out\n".to_string());
defer("something else\n".to_string());
let held = DEFERRED.lock().unwrap().clone();
assert_eq!(
held,
vec![
("lock timed out\n".to_string(), 3),
("something else\n".to_string(), 1),
],
"repeats collapse into a count, and first-seen order is kept"
);
flush_deferred();
assert!(
DEFERRED.lock().unwrap().is_empty(),
"flushing must drain, or the next TUI exit replays them"
);
}
#[test]
fn deferred_warnings_are_capped() {
let _guard = DEFERRED_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
flush_deferred();
for i in 0..(MAX_DEFERRED + 10) {
defer(format!("distinct warning {i}\n"));
}
defer("distinct warning 0\n".to_string());
let held = DEFERRED.lock().unwrap().clone();
assert_eq!(held.len(), MAX_DEFERRED);
assert_eq!(held[0].1, 2);
flush_deferred();
}
#[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());
}
}