#![allow(dead_code)]
use std::sync::Mutex;
use super::{Theme, Verbosity};
mod glyphs;
pub mod kv;
pub mod section;
pub mod status;
pub mod table;
pub(crate) use glyphs::{finalize_subject, role_glyph};
pub use status::StatusFields;
pub use table::Table;
pub(crate) struct RenderState {
indent_depth: usize,
blank_pending: bool,
leading: bool,
kv_buffer: Vec<(String, String)>,
pub(crate) section_stack: Vec<crate::output::renderer::section::SectionFrame>,
pub(crate) last_was_top_heading: bool,
}
impl RenderState {
pub(crate) fn new() -> Self {
Self {
indent_depth: 0,
blank_pending: false,
leading: true,
kv_buffer: Vec::new(),
section_stack: Vec::new(),
last_was_top_heading: false,
}
}
pub(crate) fn depth(&self) -> usize {
self.indent_depth
}
pub(crate) fn push(&mut self) -> usize {
self.indent_depth += 1;
self.indent_depth
}
pub(crate) fn pop(&mut self) {
debug_assert!(self.indent_depth > 0, "renderer pop at depth 0");
if self.indent_depth > 0 {
self.indent_depth -= 1;
}
}
}
pub struct Renderer {
pub(crate) theme: Theme,
pub(crate) verbosity: Verbosity,
pub(crate) state: Mutex<RenderState>,
}
impl Renderer {
pub fn new(theme: Theme, verbosity: Verbosity) -> Self {
Self {
theme,
verbosity,
state: Mutex::new(RenderState::new()),
}
}
pub(crate) fn indent_prefix(&self, depth: usize) -> String {
" ".repeat(depth)
}
pub(crate) fn enforce_top_level_emit(&self, expected_depth: usize) -> usize {
let actual = self.state.lock().unwrap_or_else(|e| e.into_inner()).depth();
if expected_depth == 0 && actual > 0 {
debug_assert!(
false,
"top-level emit at depth 0 while section open at depth {actual}"
);
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
tracing::warn!(
"cfgd output: top-level Printer emit reached while a SectionGuard \
was open. The emit was re-routed to the section's depth. Fix the \
call site (move it inside or outside the section)."
);
});
actual
} else {
expected_depth
}
}
}
pub trait Writer: Send + Sync {
fn write_line(&self, text: &str);
}
impl Writer for console::Term {
fn write_line(&self, text: &str) {
let _ = console::Term::write_line(self, text);
}
}
pub struct StringSink(pub std::sync::Arc<std::sync::Mutex<String>>);
impl Writer for StringSink {
fn write_line(&self, text: &str) {
let mut g = self.0.lock().unwrap_or_else(|e| e.into_inner());
g.push_str(text);
g.push('\n');
}
}
impl Renderer {
pub(crate) fn write_line(&self, w: &dyn Writer, depth: usize, body: &str) {
self.flush_kv_buffer_internal(w);
debug_assert!(
!body.contains('\n'),
"Renderer::write_line received body with embedded newline: {body:?}. \
Callers must pre-split multi-line content (see render_note for the canonical pattern)."
);
let trimmed = body.trim_end_matches(['\n', '\r']);
let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
if s.leading {
s.leading = false;
s.blank_pending = false;
} else if s.blank_pending {
w.write_line("");
s.blank_pending = false;
}
s.last_was_top_heading = false;
let prefix = " ".repeat(depth);
for line in trimmed.split('\n') {
w.write_line(&format!("{}{}", prefix, line));
}
}
fn flush_kv_buffer_internal(&self, w: &dyn Writer) {
let (pairs, depth) = {
let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
if s.kv_buffer.is_empty() {
return;
}
(std::mem::take(&mut s.kv_buffer), s.indent_depth)
};
self.render_kv_block_no_flush(w, depth, &pairs);
}
pub(crate) fn mark_blank_pending(&self) {
let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
s.blank_pending = true;
}
pub(crate) fn mark_top_level_blank_if_at_root(&self) {
let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
if s.section_stack.is_empty() {
s.blank_pending = true;
}
}
pub fn render_heading(&self, w: &dyn Writer, text: &str) {
if self.verbosity == Verbosity::Quiet {
return;
}
let styled = self.theme.header.apply_to(text).to_string();
self.write_line(w, 0, &styled);
{
let mut s = self.state.lock().unwrap_or_else(|e| e.into_inner());
if s.section_stack.is_empty() {
s.last_was_top_heading = true;
}
}
self.mark_top_level_blank_if_at_root();
}
pub fn render_bullet(&self, w: &dyn Writer, depth: usize, text: &str) {
if self.verbosity == Verbosity::Quiet {
return;
}
self.flush_pending_section_headers(w);
self.write_line(w, depth, &format!("- {}", text));
}
pub fn render_hint(&self, w: &dyn Writer, depth: usize, text: &str) {
if self.verbosity == Verbosity::Quiet {
return;
}
self.flush_pending_section_headers(w);
let arrow = self
.theme
.muted
.apply_to(format!("{} ", self.theme.icon_arrow));
let body = self.theme.muted.apply_to(text);
self.write_line(w, depth, &format!("{}{}", arrow, body));
self.mark_top_level_blank_if_at_root();
}
pub fn render_note(&self, w: &dyn Writer, depth: usize, text: &str) {
if self.verbosity != Verbosity::Verbose {
return;
}
self.flush_pending_section_headers(w);
for line in text.lines() {
let dim = self.theme.muted.apply_to(line);
self.write_line(w, depth, &dim.to_string());
}
self.mark_top_level_blank_if_at_root();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresh_renderer_at_depth_0() {
let r = Renderer::new(Theme::default(), Verbosity::Normal);
assert_eq!(r.state.lock().unwrap().depth(), 0);
}
#[test]
fn push_pop_balances() {
let r = Renderer::new(Theme::default(), Verbosity::Normal);
let mut s = r.state.lock().unwrap();
assert_eq!(s.push(), 1);
assert_eq!(s.push(), 2);
s.pop();
s.pop();
assert_eq!(s.depth(), 0);
}
#[test]
fn indent_prefix_uses_two_spaces_per_level() {
let r = Renderer::new(Theme::default(), Verbosity::Normal);
assert_eq!(r.indent_prefix(0), "");
assert_eq!(r.indent_prefix(1), " ");
assert_eq!(r.indent_prefix(3), " ");
}
use std::sync::{Arc, Mutex};
fn capture() -> (Renderer, StringSink, Arc<Mutex<String>>) {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Normal);
(r, sink, buf)
}
#[test]
fn no_leading_blank() {
let (r, sink, buf) = capture();
r.mark_blank_pending(); r.write_line(&sink, 0, "first");
let s = buf.lock().unwrap();
assert_eq!(*s, "first\n");
}
#[test]
fn one_blank_between_siblings() {
let (r, sink, buf) = capture();
r.write_line(&sink, 0, "A");
r.mark_blank_pending();
r.mark_blank_pending(); r.write_line(&sink, 0, "B");
let s = buf.lock().unwrap();
assert_eq!(*s, "A\n\nB\n");
}
#[test]
fn indent_two_spaces_per_level() {
let (r, sink, buf) = capture();
r.write_line(&sink, 0, "root");
r.write_line(&sink, 1, "child");
r.write_line(&sink, 2, "grand");
let s = buf.lock().unwrap();
assert_eq!(*s, "root\n child\n grand\n");
}
#[test]
fn heading_renders_at_depth_zero() {
let (r, sink, buf) = capture();
r.render_heading(&sink, "Status");
let s = buf.lock().unwrap();
assert!(s.contains("Status"));
assert!(!s.contains("==="));
}
#[test]
fn heading_suppressed_when_quiet() {
let (r_default, _, _) = capture();
drop(r_default);
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Quiet);
r.render_heading(&sink, "Status");
assert!(buf.lock().unwrap().is_empty());
}
#[test]
fn bullet_uses_dash_glyph() {
let (r, sink, buf) = capture();
r.render_bullet(&sink, 1, "foo");
let s = buf.lock().unwrap();
assert!(s.contains(" - foo"), "got: {s:?}");
}
#[test]
fn bullet_quiet_suppressed() {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Quiet);
r.render_bullet(&sink, 1, "foo");
assert!(buf.lock().unwrap().is_empty());
}
#[test]
fn hint_uses_arrow_glyph() {
let (r, sink, buf) = capture();
r.render_hint(&sink, 0, "run cfgd apply");
let s = buf.lock().unwrap();
assert!(s.contains("→"), "got: {s:?}");
assert!(s.contains("run cfgd apply"));
}
#[test]
fn note_suppressed_at_normal() {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Normal);
r.render_note(&sink, 0, "long prose");
assert!(buf.lock().unwrap().is_empty());
}
#[test]
fn note_shown_at_verbose() {
let buf = Arc::new(Mutex::new(String::new()));
let sink = StringSink(buf.clone());
let r = Renderer::new(Theme::default(), Verbosity::Verbose);
r.render_note(&sink, 0, "line1\nline2");
let s = buf.lock().unwrap();
assert!(s.contains("line1"));
assert!(s.contains("line2"));
}
}