//! Developer-experience (DX) diagnostics: rich, colored terminal output. //! //! Provides [`Diagnostic`] for structured error, warning, info, and hint //! messages with optional error codes, suggestions, and file/route context. //! Respects the `NO_COLOR` environment variable and detects whether stdout //! is a TTY, falling back to plain text when colours are inappropriate. use std::fmt; use std::io::IsTerminal; // ── ANSI escape helpers ────────────────────────────────────────────── const RESET: &str = "\x1b[0m"; const BOLD: &str = "\x1b[1m"; const RED: &str = "\x1b[31m"; const YELLOW: &str = "\x1b[33m"; const CYAN: &str = "\x1b[36m"; const GREEN: &str = "\x1b[32m"; const WHITE: &str = "\x1b[37m"; /// Returns `true` when colour output should be used. /// /// Colour is enabled when: /// - `NO_COLOR` is **not** set (see ), **and** /// - stdout is a terminal (not piped/redirected). pub fn colors_enabled() -> bool { std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal() } // ── DiagLevel ──────────────────────────────────────────────────────── /// Severity level for a [`Diagnostic`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum DiagLevel { /// A fatal problem that prevents the operation from completing. Error, /// A non-fatal issue that deserves attention. Warning, /// Informational message. Info, /// A gentle suggestion for improvement. Hint, } impl DiagLevel { /// Human-readable label, e.g. `"error"`. fn label(self) -> &'static str { match self { Self::Error => "error", Self::Warning => "warning", Self::Info => "info", Self::Hint => "hint", } } /// ANSI colour sequence for this level. fn color(self) -> &'static str { match self { Self::Error => RED, Self::Warning => YELLOW, Self::Info => CYAN, Self::Hint => GREEN, } } } // ── Diagnostic ─────────────────────────────────────────────────────── /// A styled diagnostic message with colours and suggestions. /// /// # Examples /// /// ``` /// use justapi_core::dx::{Diagnostic, DiagLevel}; /// /// let d = Diagnostic::new(DiagLevel::Error, "invalid address format") /// .code("E001") /// .context("--addr flag") /// .suggestion("use HOST:PORT, e.g. 127.0.0.1:8080"); /// /// // Render to a plain-text string (for logging / testing): /// let text = d.render_plain(); /// assert!(text.contains("error[E001]")); /// ``` #[derive(Debug, Clone)] pub struct Diagnostic { /// Severity level. pub level: DiagLevel, /// Optional machine-readable error code, e.g. `"E001"`. pub code: Option, /// One-line human description of the problem. pub message: String, /// Zero or more actionable suggestions. pub suggestions: Vec, /// Optional context such as a file path or route. pub context: Option, } impl Diagnostic { /// Create a new diagnostic at the given level. pub fn new(level: DiagLevel, message: impl Into) -> Self { Self { level, code: None, message: message.into(), suggestions: Vec::new(), context: None } } /// Attach an error code (e.g. `"E001"`). pub fn code(mut self, code: impl Into) -> Self { self.code = Some(code.into()); self } /// Add a single suggestion line. pub fn suggestion(mut self, s: impl Into) -> Self { self.suggestions.push(s.into()); self } /// Attach context (file path, route pattern, config key, …). pub fn context(mut self, ctx: impl Into) -> Self { self.context = Some(ctx.into()); self } // ── rendering ──────────────────────────────────────────────── /// Render with ANSI colours (when appropriate). pub fn display(&self) -> String { if colors_enabled() { self.render_colored() } else { self.render_plain() } } /// Render **without** any ANSI escape codes. pub fn render_plain(&self) -> String { let mut out = String::new(); // Header line: "error[E001]: message" or "warning: message" out.push_str(self.level.label()); if let Some(ref c) = self.code { out.push('['); out.push_str(&sanitize(c)); out.push(']'); } out.push_str(": "); out.push_str(&sanitize(&self.message)); out.push('\n'); // Context if let Some(ref ctx) = self.context { out.push_str(" --> "); out.push_str(&sanitize(ctx)); out.push('\n'); } // Suggestions for s in &self.suggestions { out.push_str(" = help: "); out.push_str(&sanitize(s)); out.push('\n'); } out } /// Render **with** ANSI escape codes. pub fn render_colored(&self) -> String { let color = self.level.color(); let mut out = String::new(); // Header out.push_str(BOLD); out.push_str(color); out.push_str(self.level.label()); if let Some(ref c) = self.code { out.push('['); out.push_str(&sanitize(c)); out.push(']'); } out.push_str(RESET); out.push_str(BOLD); out.push_str(WHITE); out.push_str(": "); out.push_str(&sanitize(&self.message)); out.push_str(RESET); out.push('\n'); // Context if let Some(ref ctx) = self.context { out.push_str(CYAN); out.push_str(" --> "); out.push_str(&sanitize(ctx)); out.push_str(RESET); out.push('\n'); } // Suggestions for s in &self.suggestions { out.push_str(GREEN); out.push_str(" = help: "); out.push_str(&sanitize(s)); out.push_str(RESET); out.push('\n'); } out } /// Print this diagnostic to stderr. pub fn emit(&self) { eprint!("{}", self.display()); } } impl fmt::Display for Diagnostic { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.render_plain()) } } // ── Helper: strip ANSI codes (for tests) ───────────────────────────── /// Strip all ANSI CSI escape sequences from `input`. /// /// Useful in tests that need to assert on rendered diagnostic text /// regardless of colour settings. Also used to sanitize user-supplied /// strings before rendering to prevent ANSI escape sequence injection. pub fn strip_ansi(input: &str) -> String { let mut out = String::with_capacity(input.len()); let mut chars = input.chars(); while let Some(ch) = chars.next() { if ch == '\x1b' { // consume until we see a letter (the terminator of a CSI sequence) for inner in chars.by_ref() { if inner.is_ascii_alphabetic() { break; } } } else { out.push(ch); } } out } /// Sanitize a user-supplied string by stripping ANSI escape sequences. /// This prevents terminal injection attacks when rendering diagnostics. fn sanitize(s: &str) -> String { strip_ansi(s) } // ── Tests ──────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn plain_error_with_code_and_suggestion() { let d = Diagnostic::new(DiagLevel::Error, "invalid address format") .code("E001") .context("--addr 999.999.999.999:99999") .suggestion("use HOST:PORT, e.g. 127.0.0.1:8080"); let text = d.render_plain(); assert!(text.starts_with("error[E001]: invalid address format\n")); assert!(text.contains(" --> --addr 999.999.999.999:99999")); assert!(text.contains(" = help: use HOST:PORT")); } #[test] fn plain_warning_no_code() { let d = Diagnostic::new(DiagLevel::Warning, "deprecated config key"); let text = d.render_plain(); assert!(text.starts_with("warning: deprecated config key\n")); // No code brackets assert!(!text.contains('[')); } #[test] fn colored_output_contains_ansi() { let d = Diagnostic::new(DiagLevel::Info, "server listening").context("0.0.0.0:8080"); let colored = d.render_colored(); assert!(colored.contains("\x1b[")); // Stripping ANSI should yield the plain version let stripped = strip_ansi(&colored); assert!(stripped.contains("info: server listening")); assert!(stripped.contains(" --> 0.0.0.0:8080")); } #[test] fn no_color_env_respected() { // Temporarily set NO_COLOR std::env::set_var("NO_COLOR", "1"); let enabled = colors_enabled(); std::env::remove_var("NO_COLOR"); // NO_COLOR was set, so colours should have been disabled regardless of TTY. assert!(!enabled); } #[test] fn strip_ansi_roundtrip() { let raw = "\x1b[1m\x1b[31merror\x1b[0m: boom"; let stripped = strip_ansi(raw); assert_eq!(stripped, "error: boom"); } #[test] fn multiple_suggestions_rendered() { let d = Diagnostic::new(DiagLevel::Hint, "improve performance") .suggestion("enable compression") .suggestion("use HTTP/2"); let text = d.render_plain(); assert_eq!(text.matches("= help:").count(), 2); } #[test] fn display_trait_uses_plain() { let d = Diagnostic::new(DiagLevel::Error, "oops").code("E099"); let via_display = format!("{d}"); assert!(via_display.contains("error[E099]: oops")); // Display should never include ANSI assert!(!via_display.contains("\x1b[")); } }