//! Structured friendly-error payload (ADR-0019 §7). //! //! `FriendlyError` is the type the translator produces. The //! renderer (in this module) composes it into final text. The //! payload is structured so the renderer can decide layout — //! per ADR-0019 §F-detail, "leave rendering details to the //! renderer instead of banging everything in one string". //! //! Composition order: //! //! ```text //! //! //! (only when present) //! //! ┌────────────┬───────┐ (only when present) //! │ … bordered │ table │ //! └────────────┴───────┘ //! ``` //! //! Blank-line separators sit between the three blocks. The //! diagnostic table uses ADR-0017's bordered renderer (via //! `output_render::render_diagnostic_table`) so its visual //! style matches the lossy / incompatible refusal diagnostics //! introduced there. use crate::output_render::{Alignment, render_diagnostic_table}; /// Bordered row pinpoint produced by the translator's /// post-failure re-query (ADR-0019 §6). The renderer hands /// these straight to ADR-0017's `render_diagnostic_table`. #[derive(Debug, Clone)] pub struct DiagnosticTable { pub headers: Vec, pub rows: Vec>, pub alignments: Vec, } /// Structured friendly-error payload (ADR-0019 §7). /// /// `headline` is always present — a single, complete line of /// "what happened". `hint` is the verbose-mode addition: one or /// more lines of pedagogical "what to do next". `diagnostic_table` /// is the (optional) row pinpoint surfaced through ADR-0017's /// bordered renderer. #[derive(Debug, Clone)] pub struct FriendlyError { pub headline: String, pub hint: Option, pub diagnostic_table: Option, } impl FriendlyError { /// Compose the payload into a single multi-line `String`. /// Newline-separated; safe to feed into `note_error()`, /// `eprintln!`, or any other plain-text sink. #[must_use] pub fn render(&self) -> String { self.render_lines().join("\n") } /// Compose the payload into a sequence of display lines. /// Used by the App layer where each output line is its own /// `OutputLine` for accurate scroll-position math (per the /// `App::push_multiline` invariant). #[must_use] pub fn render_lines(&self) -> Vec { let mut out: Vec = Vec::new(); // Headline can itself be multi-line if the catalog // entry has embedded newlines (rare today; possible // for future contributions). Split so each display // line is its own entry. for line in self.headline.lines() { out.push(line.to_string()); } if let Some(hint) = &self.hint { out.push(String::new()); for line in hint.lines() { out.push(line.to_string()); } } if let Some(table) = &self.diagnostic_table { out.push(String::new()); out.extend(render_diagnostic_table( &table.headers, &table.rows, &table.alignments, )); } out } } #[cfg(test)] mod tests { use super::*; fn fe(headline: &str) -> FriendlyError { FriendlyError { headline: headline.to_string(), hint: None, diagnostic_table: None, } } #[test] fn renders_headline_only_when_no_hint_no_table() { let f = fe("`Customers.id` already has the value `5`."); let s = f.render(); assert_eq!(s, "`Customers.id` already has the value `5`."); } #[test] fn renders_headline_blank_then_hint() { let f = FriendlyError { headline: "`Customers.id` already has the value `5`.".to_string(), hint: Some("Pick a different value or update the existing row.".to_string()), diagnostic_table: None, }; let lines = f.render_lines(); assert_eq!( lines, vec![ "`Customers.id` already has the value `5`.".to_string(), String::new(), "Pick a different value or update the existing row.".to_string(), ], ); } #[test] fn renders_headline_blank_table_with_no_hint() { let f = FriendlyError { headline: "rows still reference this".to_string(), hint: None, diagnostic_table: Some(DiagnosticTable { headers: vec!["id".to_string(), "name".to_string()], rows: vec![ vec!["1".to_string(), "Alice".to_string()], vec!["2".to_string(), "Bob".to_string()], ], alignments: vec![Alignment::Right, Alignment::Left], }), }; let lines = f.render_lines(); // headline + blank + bordered table (5+ lines). assert_eq!(lines[0], "rows still reference this"); assert_eq!(lines[1], ""); // The first table line is the top border. assert!( lines[2].starts_with('┌'), "expected bordered table after headline, got: {:?}", lines[2] ); // And the headers contain `id` and `name`. assert!( lines.iter().any(|l| l.contains("id") && l.contains("name")), "missing headers row: {lines:?}" ); } #[test] fn renders_all_three_with_blank_separators() { let f = FriendlyError { headline: "headline".to_string(), hint: Some("hint".to_string()), diagnostic_table: Some(DiagnosticTable { headers: vec!["c".to_string()], rows: vec![vec!["v".to_string()]], alignments: vec![Alignment::Left], }), }; let lines = f.render_lines(); // headline, blank, hint, blank, then 5 lines of table // = 9 minimum. We assert order rather than exact length // so future renderer tweaks don't trip the test. assert_eq!(lines[0], "headline"); assert_eq!(lines[1], ""); assert_eq!(lines[2], "hint"); assert_eq!(lines[3], ""); assert!(lines[4].starts_with('┌')); } #[test] fn multi_line_headline_splits_at_newlines() { // Future-proofing: if a catalog entry ever embeds a // newline in the headline, the renderer treats each // line independently rather than emitting one long row. let f = fe("first line\nsecond line"); let lines = f.render_lines(); assert_eq!(lines[0], "first line"); assert_eq!(lines[1], "second line"); } }