Skip to main content

cfgd_core/output/renderer/
mod.rs

1//! The renderer is the single layout authority. It owns:
2//! - indent depth (push/pop per Section)
3//! - blank-line state machine (no leading, no trailing, exactly one between siblings)
4//! - kv auto-batching (consecutive `kv` calls coalesce into one aligned block)
5//! - glyph + style lookup via Theme
6//!
7//! Every other module routes terminal writes through here.
8//!
9//! `RenderState::{depth,push,pop}` and `indent_prefix` are reachable only
10//! from tests and from inside the renderer module; the narrow `dead_code`
11//! allow keeps them addressable without a workspace-wide warning.
12#![allow(dead_code)]
13
14use std::sync::Mutex;
15
16use super::{Theme, Verbosity};
17
18mod glyphs;
19pub mod kv;
20pub mod section;
21pub mod status;
22pub mod table;
23pub(crate) use glyphs::{finalize_subject, role_glyph};
24pub use status::StatusFields;
25pub use table::Table;
26
27/// Per-Printer rendering state. Held inside `Mutex` because multiple
28/// `SectionGuard`s may share the same `&Printer` and write concurrently
29/// from one thread (drop ordering is single-threaded but borrow-checker
30/// can't see that).
31pub(crate) struct RenderState {
32    /// Current indent depth. Section open = +1, section close = -1.
33    indent_depth: usize,
34    /// True if the renderer should emit a blank line before the next non-blank
35    /// emission (set by section close, cleared by next emit).
36    blank_pending: bool,
37    /// True until the first emission lands; suppresses leading blank.
38    leading: bool,
39    /// Buffered kvs awaiting a non-kv emission to flush as one aligned block.
40    kv_buffer: Vec<(String, String)>,
41    pub(crate) section_stack: Vec<crate::output::renderer::section::SectionFrame>,
42    /// True iff the most recent emission was a top-level heading and no other
43    /// emission has happened since. Consumed by the next top-level kv_block,
44    /// which re-anchors the block at depth+1 so it visually nests under the
45    /// heading. Reset by any other emission (status, section header, bullet,
46    /// etc.).
47    pub(crate) last_was_top_heading: bool,
48}
49
50impl RenderState {
51    pub(crate) fn new() -> Self {
52        Self {
53            indent_depth: 0,
54            blank_pending: false,
55            leading: true,
56            kv_buffer: Vec::new(),
57            section_stack: Vec::new(),
58            last_was_top_heading: false,
59        }
60    }
61
62    pub(crate) fn depth(&self) -> usize {
63        self.indent_depth
64    }
65
66    pub(crate) fn push(&mut self) -> usize {
67        self.indent_depth += 1;
68        self.indent_depth
69    }
70
71    pub(crate) fn pop(&mut self) {
72        debug_assert!(self.indent_depth > 0, "renderer pop at depth 0");
73        if self.indent_depth > 0 {
74            self.indent_depth -= 1;
75        }
76    }
77}
78
79/// Renderer is created per Printer. All state lives in `RenderState` behind a
80/// Mutex so the caller doesn't see interior mutability.
81pub struct Renderer {
82    pub(crate) theme: Theme,
83    pub(crate) verbosity: Verbosity,
84    pub(crate) state: Mutex<RenderState>,
85}
86
87impl Renderer {
88    pub fn new(theme: Theme, verbosity: Verbosity) -> Self {
89        Self {
90            theme,
91            verbosity,
92            state: Mutex::new(RenderState::new()),
93        }
94    }
95
96    /// Build the indent prefix for the current depth.
97    pub(crate) fn indent_prefix(&self, depth: usize) -> String {
98        "  ".repeat(depth)
99    }
100
101    /// Called by every top-level emit before writing. Returns the depth at
102    /// which the emit should actually render (clamped to current open section).
103    ///
104    /// A top-level emit (depth 0) reached while a `SectionGuard` is alive is
105    /// a programming error. Debug builds `debug_assert!` to flag the call
106    /// site loudly; release builds log a `tracing::warn!` once per process
107    /// and re-route the emit to the section's current depth so the output
108    /// stays readable.
109    pub(crate) fn enforce_top_level_emit(&self, expected_depth: usize) -> usize {
110        let actual = self.state.lock().unwrap_or_else(|e| e.into_inner()).depth();
111        if expected_depth == 0 && actual > 0 {
112            // Top-level emit while a section is open.
113            debug_assert!(
114                false,
115                "top-level emit at depth 0 while section open at depth {actual}"
116            );
117            // Release build: warn once, render at the section's depth.
118            // Process-global: test runs observe at most one warning across the entire suite.
119            static WARNED: std::sync::Once = std::sync::Once::new();
120            WARNED.call_once(|| {
121                tracing::warn!(
122                    "cfgd output: top-level Printer emit reached while a SectionGuard \
123                     was open. The emit was re-routed to the section's depth. Fix the \
124                     call site (move it inside or outside the section)."
125                );
126            });
127            actual
128        } else {
129            expected_depth
130        }
131    }
132}
133
134/// Sink for one rendered line. Production = stderr Term; tests = string buffer.
135pub trait Writer: Send + Sync {
136    fn write_line(&self, text: &str);
137}
138
139impl Writer for console::Term {
140    fn write_line(&self, text: &str) {
141        let _ = console::Term::write_line(self, text);
142    }
143}
144
145pub struct StringSink(pub std::sync::Arc<std::sync::Mutex<String>>);
146impl Writer for StringSink {
147    fn write_line(&self, text: &str) {
148        let mut g = self.0.lock().unwrap_or_else(|e| e.into_inner());
149        g.push_str(text);
150        g.push('\n');
151    }
152}
153
154impl Renderer {
155    /// Emit a single physical line at the given depth, honoring blank-pending.
156    ///
157    /// Flushes any pending kvs first — otherwise buffered kvs would render
158    /// *after* this non-kv line, inverting the call order. kv emission paths
159    /// must call `w.write_line(...)` directly (NOT `self.write_line`) to avoid
160    /// recursing back into `flush_kv_buffer_internal`.
161    pub(crate) fn write_line(&self, w: &dyn Writer, depth: usize, body: &str) {
162        self.flush_kv_buffer_internal(w);
163        debug_assert!(
164            !body.contains('\n'),
165            "Renderer::write_line received body with embedded newline: {body:?}. \
166             Callers must pre-split multi-line content (see render_note for the canonical pattern)."
167        );
168        // Callers must pre-split multi-line content; we normalize embedded \n
169        // defensively to keep blank-line accounting honest if they don't. The
170        // sink appends its own trailing newline per call; any newlines
171        // already in `body` would smuggle physical line breaks past the
172        // blank-line accounting (e.g. a Status subject ending with `\n` would
173        // produce a stray blank between this emission and the next, breaking
174        // the one-blank-between-siblings invariant). Strip trailing newlines
175        // and split internal ones into separate sink writes at the same
176        // depth — `render_note` is the only intentional multi-line path and
177        // pre-splits before calling here.
178        let trimmed = body.trim_end_matches(['\n', '\r']);
179        let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
180        if s.leading {
181            s.leading = false;
182            s.blank_pending = false;
183        } else if s.blank_pending {
184            w.write_line("");
185            s.blank_pending = false;
186        }
187        // Any emission resets the heading-just-emitted flag. Heading itself
188        // sets the flag back true after this call returns.
189        s.last_was_top_heading = false;
190        let prefix = "  ".repeat(depth);
191        for line in trimmed.split('\n') {
192            w.write_line(&format!("{}{}", prefix, line));
193        }
194    }
195
196    /// Inner kv-buffer flush invoked from `write_line`. Does NOT recurse — it
197    /// calls `render_kv_block_no_flush` directly, which uses `w.write_line` for
198    /// every emission rather than `self.write_line`.
199    fn flush_kv_buffer_internal(&self, w: &dyn Writer) {
200        let (pairs, depth) = {
201            let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
202            if s.kv_buffer.is_empty() {
203                return;
204            }
205            (std::mem::take(&mut s.kv_buffer), s.indent_depth)
206        };
207        self.render_kv_block_no_flush(w, depth, &pairs);
208    }
209
210    /// Mark that the next non-blank emission should be preceded by exactly
211    /// one blank line. Called by Section close.
212    pub(crate) fn mark_blank_pending(&self) {
213        let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
214        s.blank_pending = true;
215    }
216
217    /// Set blank-pending iff we're at the root group level (no open section).
218    /// Called at the end of every top-level group emission (heading, kv_block,
219    /// status, hint, note, table) so the next top-level emit gets one blank.
220    /// One blank line precedes every top-level group after the first.
221    pub(crate) fn mark_top_level_blank_if_at_root(&self) {
222        let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
223        if s.section_stack.is_empty() {
224            s.blank_pending = true;
225        }
226    }
227
228    /// Heading: bold styled by Theme::header. No `=== ===` decoration. Always depth 0.
229    pub fn render_heading(&self, w: &dyn Writer, text: &str) {
230        if self.verbosity == Verbosity::Quiet {
231            return;
232        }
233        let styled = self.theme.header.apply_to(text).to_string();
234        self.write_line(w, 0, &styled);
235        // Set the heading-just-emitted flag AFTER write_line (which clears
236        // it). The next top-level kv_block consumes this to re-anchor itself
237        // at depth+1 so it visually nests under the heading.
238        {
239            let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
240            if s.section_stack.is_empty() {
241                s.last_was_top_heading = true;
242            }
243        }
244        self.mark_top_level_blank_if_at_root();
245    }
246
247    /// Bullet: glyph `-`, then space, then text. Uncolored. The renderer's only
248    /// bullet glyph; `+`/`~`/`>`/`*` are forbidden.
249    pub fn render_bullet(&self, w: &dyn Writer, depth: usize, text: &str) {
250        if self.verbosity == Verbosity::Quiet {
251            return;
252        }
253        self.flush_pending_section_headers(w);
254        self.write_line(w, depth, &format!("- {}", text));
255    }
256
257    /// Hint: arrow glyph + dim text. Shown at Normal+ (NOT Quiet). The
258    /// canonical "next step" surface.
259    pub fn render_hint(&self, w: &dyn Writer, depth: usize, text: &str) {
260        if self.verbosity == Verbosity::Quiet {
261            return;
262        }
263        self.flush_pending_section_headers(w);
264        let arrow = self
265            .theme
266            .muted
267            .apply_to(format!("{} ", self.theme.icon_arrow));
268        let body = self.theme.muted.apply_to(text);
269        self.write_line(w, depth, &format!("{}{}", arrow, body));
270        self.mark_top_level_blank_if_at_root();
271    }
272
273    /// Note: multi-line prose. Suppressed at both Quiet and Normal; only Verbose.
274    pub fn render_note(&self, w: &dyn Writer, depth: usize, text: &str) {
275        if self.verbosity != Verbosity::Verbose {
276            return;
277        }
278        self.flush_pending_section_headers(w);
279        for line in text.lines() {
280            let dim = self.theme.muted.apply_to(line);
281            self.write_line(w, depth, &dim.to_string());
282        }
283        self.mark_top_level_blank_if_at_root();
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn fresh_renderer_at_depth_0() {
293        let r = Renderer::new(Theme::default(), Verbosity::Normal);
294        assert_eq!(r.state.lock().unwrap().depth(), 0);
295    }
296
297    #[test]
298    fn push_pop_balances() {
299        let r = Renderer::new(Theme::default(), Verbosity::Normal);
300        let mut s = r.state.lock().unwrap();
301        assert_eq!(s.push(), 1);
302        assert_eq!(s.push(), 2);
303        s.pop();
304        s.pop();
305        assert_eq!(s.depth(), 0);
306    }
307
308    #[test]
309    fn indent_prefix_uses_two_spaces_per_level() {
310        let r = Renderer::new(Theme::default(), Verbosity::Normal);
311        assert_eq!(r.indent_prefix(0), "");
312        assert_eq!(r.indent_prefix(1), "  ");
313        assert_eq!(r.indent_prefix(3), "      ");
314    }
315
316    use std::sync::{Arc, Mutex};
317
318    fn capture() -> (Renderer, StringSink, Arc<Mutex<String>>) {
319        let buf = Arc::new(Mutex::new(String::new()));
320        let sink = StringSink(buf.clone());
321        let r = Renderer::new(Theme::default(), Verbosity::Normal);
322        (r, sink, buf)
323    }
324
325    #[test]
326    fn no_leading_blank() {
327        let (r, sink, buf) = capture();
328        r.mark_blank_pending(); // even if requested before first emit
329        r.write_line(&sink, 0, "first");
330        let s = buf.lock().unwrap();
331        assert_eq!(*s, "first\n");
332    }
333
334    #[test]
335    fn one_blank_between_siblings() {
336        let (r, sink, buf) = capture();
337        r.write_line(&sink, 0, "A");
338        r.mark_blank_pending();
339        r.mark_blank_pending(); // duplicate marks coalesce
340        r.write_line(&sink, 0, "B");
341        let s = buf.lock().unwrap();
342        assert_eq!(*s, "A\n\nB\n");
343    }
344
345    #[test]
346    fn indent_two_spaces_per_level() {
347        let (r, sink, buf) = capture();
348        r.write_line(&sink, 0, "root");
349        r.write_line(&sink, 1, "child");
350        r.write_line(&sink, 2, "grand");
351        let s = buf.lock().unwrap();
352        assert_eq!(*s, "root\n  child\n    grand\n");
353    }
354
355    #[test]
356    fn heading_renders_at_depth_zero() {
357        let (r, sink, buf) = capture();
358        r.render_heading(&sink, "Status");
359        let s = buf.lock().unwrap();
360        assert!(s.contains("Status"));
361        // No `=== ===` decoration.
362        assert!(!s.contains("==="));
363    }
364
365    #[test]
366    fn heading_suppressed_when_quiet() {
367        let (r_default, _, _) = capture();
368        drop(r_default);
369        let buf = Arc::new(Mutex::new(String::new()));
370        let sink = StringSink(buf.clone());
371        let r = Renderer::new(Theme::default(), Verbosity::Quiet);
372        r.render_heading(&sink, "Status");
373        assert!(buf.lock().unwrap().is_empty());
374    }
375
376    #[test]
377    fn bullet_uses_dash_glyph() {
378        let (r, sink, buf) = capture();
379        r.render_bullet(&sink, 1, "foo");
380        let s = buf.lock().unwrap();
381        assert!(s.contains("  - foo"), "got: {s:?}");
382    }
383
384    #[test]
385    fn bullet_quiet_suppressed() {
386        let buf = Arc::new(Mutex::new(String::new()));
387        let sink = StringSink(buf.clone());
388        let r = Renderer::new(Theme::default(), Verbosity::Quiet);
389        r.render_bullet(&sink, 1, "foo");
390        assert!(buf.lock().unwrap().is_empty());
391    }
392
393    #[test]
394    fn hint_uses_arrow_glyph() {
395        let (r, sink, buf) = capture();
396        r.render_hint(&sink, 0, "run cfgd apply");
397        let s = buf.lock().unwrap();
398        assert!(s.contains("→"), "got: {s:?}");
399        assert!(s.contains("run cfgd apply"));
400    }
401
402    #[test]
403    fn note_suppressed_at_normal() {
404        let buf = Arc::new(Mutex::new(String::new()));
405        let sink = StringSink(buf.clone());
406        let r = Renderer::new(Theme::default(), Verbosity::Normal);
407        r.render_note(&sink, 0, "long prose");
408        assert!(buf.lock().unwrap().is_empty());
409    }
410
411    #[test]
412    fn note_shown_at_verbose() {
413        let buf = Arc::new(Mutex::new(String::new()));
414        let sink = StringSink(buf.clone());
415        let r = Renderer::new(Theme::default(), Verbosity::Verbose);
416        r.render_note(&sink, 0, "line1\nline2");
417        let s = buf.lock().unwrap();
418        assert!(s.contains("line1"));
419        assert!(s.contains("line2"));
420    }
421}