systemprompt_logging/services/cli/
table.rs1use 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 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
173pub 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 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}