cfgd_core/output/renderer/
mod.rs1#![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
27pub(crate) struct RenderState {
32 indent_depth: usize,
34 blank_pending: bool,
37 leading: bool,
39 kv_buffer: Vec<(String, String)>,
41 pub(crate) section_stack: Vec<crate::output::renderer::section::SectionFrame>,
42 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
79pub 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 pub(crate) fn indent_prefix(&self, depth: usize) -> String {
98 " ".repeat(depth)
99 }
100
101 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 debug_assert!(
114 false,
115 "top-level emit at depth 0 while section open at depth {actual}"
116 );
117 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
134pub 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 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 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 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 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 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 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 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 {
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 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 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 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(); 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(); 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 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}