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 systemprompt_models::text::truncate_with_ellipsis;
15
16use crate::services::cli::sink::{report_write_failure, stdout_write, stdout_writeln};
17use crate::services::cli::theme::{BrandColors, StatusGlyph};
18
19#[derive(Debug, Clone)]
20pub struct ServiceTableEntry {
21    pub name: String,
22    pub service_type: String,
23    pub port: Option<u16>,
24    pub status: StatusGlyph,
25}
26
27impl ServiceTableEntry {
28    pub fn new(
29        name: impl Into<String>,
30        service_type: impl Into<String>,
31        port: Option<u16>,
32        status: StatusGlyph,
33    ) -> Self {
34        Self {
35            name: name.into(),
36            service_type: service_type.into(),
37            port,
38            status,
39        }
40    }
41}
42
43fn calculate_column_widths(headers: &[&str], rows: &[Vec<String>]) -> Vec<usize> {
44    let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
45
46    for row in rows {
47        for (i, cell) in row.iter().enumerate() {
48            if i < widths.len() {
49                widths[i] = widths[i].max(cell.len());
50            }
51        }
52    }
53
54    widths
55}
56
57fn render_table_border(widths: &[usize], left: &str, middle: &str, right: &str) {
58    stdout_write(format_args!("{left}"));
59    for (i, &width) in widths.iter().enumerate() {
60        stdout_write(format_args!("{}", "\u{2500}".repeat(width + 2)));
61        if i < widths.len() - 1 {
62            stdout_write(format_args!("{middle}"));
63        }
64    }
65    stdout_writeln(format_args!("{right}"));
66}
67
68fn render_table_row(cells: &[&str], widths: &[usize]) {
69    stdout_write(format_args!("\u{2502}"));
70    for (i, (&cell, &width)) in cells.iter().zip(widths.iter()).enumerate() {
71        let truncated = truncate_with_ellipsis(cell, width);
72        stdout_write(format_args!(" {truncated:<width$} "));
73        if i < widths.len() - 1 {
74            stdout_write(format_args!("\u{2502}"));
75        }
76    }
77    stdout_writeln(format_args!("\u{2502}"));
78}
79
80pub fn render_table(headers: &[&str], rows: &[Vec<String>]) {
81    if rows.is_empty() {
82        return;
83    }
84
85    let widths = calculate_column_widths(headers, rows);
86
87    render_table_border(&widths, "\u{250c}", "\u{252c}", "\u{2510}");
88    render_table_row(headers, &widths);
89    render_table_border(&widths, "\u{251c}", "\u{253c}", "\u{2524}");
90
91    for row in rows {
92        let cells: Vec<&str> = row.iter().map(String::as_str).collect();
93        render_table_row(&cells, &widths);
94    }
95
96    render_table_border(&widths, "\u{2514}", "\u{2534}", "\u{2518}");
97}
98
99struct ServiceColumns {
100    name: usize,
101    service_type: usize,
102    port: usize,
103    status: usize,
104}
105
106impl ServiceColumns {
107    fn measure(services: &[ServiceTableEntry]) -> Self {
108        let name = services
109            .iter()
110            .map(|s| s.name.chars().count())
111            .max()
112            .unwrap_or(4)
113            .max(4);
114        let service_type = services
115            .iter()
116            .map(|s| s.service_type.chars().count())
117            .max()
118            .unwrap_or(4)
119            .max(4);
120        Self {
121            name,
122            service_type,
123            port: 5,
124            status: 10,
125        }
126    }
127
128    const fn interior_width(&self) -> usize {
129        (self.name + 2) + (self.service_type + 2) + (self.port + 2) + (self.status + 2) + 3
130    }
131
132    fn rule(
133        &self,
134        out: &mut impl Write,
135        left: &str,
136        middle: &str,
137        right: &str,
138    ) -> std::io::Result<()> {
139        writeln!(
140            out,
141            "{left}{}{middle}{}{middle}{}{middle}{}{right}",
142            "\u{2500}".repeat(self.name + 2),
143            "\u{2500}".repeat(self.service_type + 2),
144            "\u{2500}".repeat(self.port + 2),
145            "\u{2500}".repeat(self.status + 2)
146        )
147    }
148}
149
150pub fn render_service_table(title: &str, services: &[ServiceTableEntry]) {
151    if let Err(error) = render_service_table_into(&mut std::io::stdout(), title, services) {
152        report_write_failure("stdout", &error);
153    }
154}
155
156pub fn render_service_table_into(
157    out: &mut impl Write,
158    title: &str,
159    services: &[ServiceTableEntry],
160) -> std::io::Result<()> {
161    if services.is_empty() {
162        return Ok(());
163    }
164
165    let cols = ServiceColumns::measure(services);
166    let interior = cols.interior_width();
167
168    writeln!(out)?;
169    writeln!(out, "\u{250c}{}\u{2510}", "\u{2500}".repeat(interior))?;
170    writeln!(
171        out,
172        "\u{2502} {:<width$} \u{2502}",
173        BrandColors::white_bold(title),
174        width = interior - 2
175    )?;
176
177    cols.rule(out, "\u{251c}", "\u{252c}", "\u{2524}")?;
178    render_service_header(out, &cols)?;
179    cols.rule(out, "\u{251c}", "\u{253c}", "\u{2524}")?;
180
181    for service in services {
182        render_service_row(out, service, &cols)?;
183    }
184
185    cols.rule(out, "\u{2514}", "\u{2534}", "\u{2518}")
186}
187
188fn render_service_header(out: &mut impl Write, cols: &ServiceColumns) -> std::io::Result<()> {
189    let name_width = cols.name;
190    let type_width = cols.service_type;
191    let port_width = cols.port;
192    let status_width = cols.status;
193    writeln!(
194        out,
195        "\u{2502} {:<name_width$} \u{2502} {:<type_width$} \u{2502} {:>port_width$} \u{2502} \
196         {:<status_width$} \u{2502}",
197        BrandColors::dim("Name"),
198        BrandColors::dim("Type"),
199        BrandColors::dim("Port"),
200        BrandColors::dim("Status"),
201    )
202}
203
204fn render_service_row(
205    out: &mut impl Write,
206    service: &ServiceTableEntry,
207    cols: &ServiceColumns,
208) -> std::io::Result<()> {
209    let name_width = cols.name;
210    let type_width = cols.service_type;
211    let port_width = cols.port;
212
213    let port_str = service
214        .port
215        .map_or_else(|| "-".to_owned(), |p| p.to_string());
216
217    let status_display = format!("{} {}", service.status.symbol(), service.status.text());
218    // Why: Rust string formatting counts ANSI escape characters toward field width.
219    let padded_status = format!("{status_display:<width$}", width = cols.status);
220    let colored_status = match service.status {
221        StatusGlyph::Running => BrandColors::running(padded_status),
222        StatusGlyph::Starting => BrandColors::starting(padded_status),
223        StatusGlyph::Stopped | StatusGlyph::Failed => BrandColors::stopped(padded_status),
224        StatusGlyph::Unknown => BrandColors::dim(padded_status),
225    };
226
227    writeln!(
228        out,
229        "\u{2502} {:<name_width$} \u{2502} {:<type_width$} \u{2502} {:>port_width$} \u{2502} \
230         {colored_status} \u{2502}",
231        truncate_with_ellipsis(&service.name, name_width),
232        truncate_with_ellipsis(&service.service_type, type_width),
233        port_str,
234    )
235}
236
237pub fn render_startup_complete(duration: Duration, api_url: &str) {
238    let secs = duration.as_secs_f64();
239    stdout_writeln(format_args!(""));
240    stdout_writeln(format_args!(
241        "{} {} {}",
242        BrandColors::running("\u{2713}"),
243        BrandColors::white_bold("All services started successfully"),
244        BrandColors::dim(format!("({:.1}s)", secs))
245    ));
246    stdout_writeln(format_args!(
247        "  {} {}",
248        BrandColors::dim("API:"),
249        BrandColors::highlight(api_url)
250    ));
251    stdout_writeln(format_args!(""));
252}