Skip to main content

systemprompt_logging/services/cli/
table.rs

1//! Box-drawing table renderers for CLI output.
2//!
3//! [`render_table`] draws an arbitrary header/row grid;
4//! [`render_service_table`] renders the service-status table from
5//! [`ServiceTableEntry`] values; and [`render_startup_complete`] prints the
6//! post-boot summary. Output goes to stdout via this sanctioned display sink.
7//!
8//! Copyright (c) systemprompt.io — Business Source License 1.1.
9//! See <https://systemprompt.io> for licensing details.
10
11use std::io::Write;
12use std::time::Duration;
13
14use crate::services::cli::theme::{BrandColors, ServiceStatus};
15
16fn stdout_write(args: std::fmt::Arguments<'_>) {
17    let mut out = std::io::stdout();
18    write!(out, "{args}").ok();
19}
20
21fn stdout_writeln(args: std::fmt::Arguments<'_>) {
22    let mut out = std::io::stdout();
23    writeln!(out, "{args}").ok();
24}
25
26#[derive(Debug, Clone)]
27pub struct ServiceTableEntry {
28    pub name: String,
29    pub service_type: String,
30    pub port: Option<u16>,
31    pub status: ServiceStatus,
32}
33
34impl ServiceTableEntry {
35    pub fn new(
36        name: impl Into<String>,
37        service_type: impl Into<String>,
38        port: Option<u16>,
39        status: ServiceStatus,
40    ) -> Self {
41        Self {
42            name: name.into(),
43            service_type: service_type.into(),
44            port,
45            status,
46        }
47    }
48}
49
50pub fn truncate_to_width(s: &str, width: usize) -> String {
51    if s.chars().count() <= width {
52        return s.to_owned();
53    }
54    let truncate_to = width.saturating_sub(3);
55    let truncated: String = s.chars().take(truncate_to).collect();
56    format!("{truncated}...")
57}
58
59fn calculate_column_widths(headers: &[&str], rows: &[Vec<String>]) -> Vec<usize> {
60    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
61
62    for row in rows {
63        for (i, cell) in row.iter().enumerate() {
64            if i < widths.len() {
65                widths[i] = widths[i].max(cell.len());
66            }
67        }
68    }
69
70    widths
71}
72
73fn render_table_border(widths: &[usize], left: &str, middle: &str, right: &str) {
74    stdout_write(format_args!("{left}"));
75    for (i, &width) in widths.iter().enumerate() {
76        stdout_write(format_args!("{}", "\u{2500}".repeat(width + 2)));
77        if i < widths.len() - 1 {
78            stdout_write(format_args!("{middle}"));
79        }
80    }
81    stdout_writeln(format_args!("{right}"));
82}
83
84fn render_table_row(cells: &[&str], widths: &[usize]) {
85    stdout_write(format_args!("\u{2502}"));
86    for (i, (&cell, &width)) in cells.iter().zip(widths.iter()).enumerate() {
87        let truncated = truncate_to_width(cell, width);
88        stdout_write(format_args!(" {truncated:<width$} "));
89        if i < widths.len() - 1 {
90            stdout_write(format_args!("\u{2502}"));
91        }
92    }
93    stdout_writeln(format_args!("\u{2502}"));
94}
95
96pub fn render_table(headers: &[&str], rows: &[Vec<String>]) {
97    if rows.is_empty() {
98        return;
99    }
100
101    let widths = calculate_column_widths(headers, rows);
102
103    render_table_border(&widths, "\u{250c}", "\u{252c}", "\u{2510}");
104    render_table_row(headers, &widths);
105    render_table_border(&widths, "\u{251c}", "\u{253c}", "\u{2524}");
106
107    for row in rows {
108        let cells: Vec<&str> = row.iter().map(String::as_str).collect();
109        render_table_row(&cells, &widths);
110    }
111
112    render_table_border(&widths, "\u{2514}", "\u{2534}", "\u{2518}");
113}
114
115struct ServiceColumns {
116    name: usize,
117    service_type: usize,
118    port: usize,
119    status: usize,
120}
121
122impl ServiceColumns {
123    fn measure(services: &[ServiceTableEntry]) -> Self {
124        let name = services
125            .iter()
126            .map(|s| s.name.chars().count())
127            .max()
128            .unwrap_or(4)
129            .max(4);
130        let service_type = services
131            .iter()
132            .map(|s| s.service_type.chars().count())
133            .max()
134            .unwrap_or(4)
135            .max(4);
136        Self {
137            name,
138            service_type,
139            port: 5,
140            status: 10,
141        }
142    }
143
144    /// Display width of a row's interior, i.e. everything strictly between the
145    /// two outer box-drawing glyphs. Every line of the table is framed against
146    /// this one number so the borders, title, header, and rows cannot drift.
147    const fn interior_width(&self) -> usize {
148        (self.name + 2) + (self.service_type + 2) + (self.port + 2) + (self.status + 2) + 3
149    }
150
151    fn rule(
152        &self,
153        out: &mut impl Write,
154        left: &str,
155        middle: &str,
156        right: &str,
157    ) -> std::io::Result<()> {
158        writeln!(
159            out,
160            "{left}{}{middle}{}{middle}{}{middle}{}{right}",
161            "\u{2500}".repeat(self.name + 2),
162            "\u{2500}".repeat(self.service_type + 2),
163            "\u{2500}".repeat(self.port + 2),
164            "\u{2500}".repeat(self.status + 2)
165        )
166    }
167}
168
169pub fn render_service_table(title: &str, services: &[ServiceTableEntry]) {
170    render_service_table_into(&mut std::io::stdout(), title, services).ok();
171}
172
173/// Renders the service-status table into `out`.
174///
175/// Split from [`render_service_table`] so the frame geometry is assertable: the
176/// stdout entry point above discards the result, which is why an off-by-two in
177/// the top border went unnoticed.
178pub fn render_service_table_into(
179    out: &mut impl Write,
180    title: &str,
181    services: &[ServiceTableEntry],
182) -> std::io::Result<()> {
183    if services.is_empty() {
184        return Ok(());
185    }
186
187    let cols = ServiceColumns::measure(services);
188    let interior = cols.interior_width();
189
190    writeln!(out)?;
191    writeln!(out, "\u{250c}{}\u{2510}", "\u{2500}".repeat(interior))?;
192    writeln!(
193        out,
194        "\u{2502} {:<width$} \u{2502}",
195        BrandColors::white_bold(title),
196        width = interior - 2
197    )?;
198
199    cols.rule(out, "\u{251c}", "\u{252c}", "\u{2524}")?;
200    render_service_header(out, &cols)?;
201    cols.rule(out, "\u{251c}", "\u{253c}", "\u{2524}")?;
202
203    for service in services {
204        render_service_row(out, service, &cols)?;
205    }
206
207    cols.rule(out, "\u{2514}", "\u{2534}", "\u{2518}")
208}
209
210fn render_service_header(out: &mut impl Write, cols: &ServiceColumns) -> std::io::Result<()> {
211    let name_width = cols.name;
212    let type_width = cols.service_type;
213    let port_width = cols.port;
214    let status_width = cols.status;
215    writeln!(
216        out,
217        "\u{2502} {:<name_width$} \u{2502} {:<type_width$} \u{2502} {:>port_width$} \u{2502} \
218         {:<status_width$} \u{2502}",
219        BrandColors::dim("Name"),
220        BrandColors::dim("Type"),
221        BrandColors::dim("Port"),
222        BrandColors::dim("Status"),
223    )
224}
225
226fn render_service_row(
227    out: &mut impl Write,
228    service: &ServiceTableEntry,
229    cols: &ServiceColumns,
230) -> std::io::Result<()> {
231    let name_width = cols.name;
232    let type_width = cols.service_type;
233    let port_width = cols.port;
234
235    let port_str = service
236        .port
237        .map_or_else(|| "-".to_owned(), |p| p.to_string());
238
239    let status_display = format!("{} {}", service.status.symbol(), service.status.text());
240    // Why: pad before styling. A styled `String` carries ANSI escapes, and
241    // `str`'s formatter counts those bytes as content, so padding a
242    // pre-rendered status collapses the column.
243    let padded_status = format!("{status_display:<width$}", width = cols.status);
244    let colored_status = match service.status {
245        ServiceStatus::Running => BrandColors::running(padded_status),
246        ServiceStatus::Starting => BrandColors::starting(padded_status),
247        ServiceStatus::Stopped | ServiceStatus::Failed => BrandColors::stopped(padded_status),
248        ServiceStatus::Unknown => BrandColors::dim(padded_status),
249    };
250
251    writeln!(
252        out,
253        "\u{2502} {:<name_width$} \u{2502} {:<type_width$} \u{2502} {:>port_width$} \u{2502} \
254         {colored_status} \u{2502}",
255        truncate_to_width(&service.name, name_width),
256        truncate_to_width(&service.service_type, type_width),
257        port_str,
258    )
259}
260
261pub fn render_startup_complete(duration: Duration, api_url: &str) {
262    let secs = duration.as_secs_f64();
263    stdout_writeln(format_args!(""));
264    stdout_writeln(format_args!(
265        "{} {} {}",
266        BrandColors::running("\u{2713}"),
267        BrandColors::white_bold("All services started successfully"),
268        BrandColors::dim(format!("({:.1}s)", secs))
269    ));
270    stdout_writeln(format_args!(
271        "  {} {}",
272        BrandColors::dim("API:"),
273        BrandColors::highlight(api_url)
274    ));
275    stdout_writeln(format_args!(""));
276}