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