Skip to main content

lilo_rm_core/
cli_output.rs

1use std::fmt::{self, Write};
2use std::path::Path;
3
4use serde::Serialize;
5use uuid::Uuid;
6
7use crate::{
8    DoctorResponse, KillByPidResponse, Lifecycle, LifecycleCounts, NudgeOutcome, NudgeResponse,
9    PaneSnapshot, RuntimeCapability, RuntimeEvent, RuntimeResponse, VersionInfo,
10};
11
12pub trait CliOutput: Serialize {
13    fn render_human(&self, f: &mut impl Write) -> fmt::Result;
14}
15
16#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
17pub struct Ack {
18    pub session_id: Uuid,
19}
20
21impl CliOutput for Ack {
22    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
23        writeln!(f, "kill OK; session_id={}", self.session_id)
24    }
25}
26
27impl CliOutput for VersionInfo {
28    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
29        writeln!(
30            f,
31            "version={} git_sha={} protocol={}",
32            self.version, self.git_sha, self.protocol_version
33        )
34    }
35}
36
37impl CliOutput for DoctorResponse {
38    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
39        writeln!(f, "rtmd")?;
40        writeln!(
41            f,
42            "  version             {} (git: {})",
43            self.version.version, self.version.git_sha
44        )?;
45        writeln!(f, "  protocol            {}", self.version.protocol_version)?;
46        writeln!(
47            f,
48            "  capabilities        {}",
49            format_capabilities(&self.version.capabilities)
50        )?;
51        writeln!(f, "  socket              {}", self.socket_path)?;
52        writeln!(
53            f,
54            "  uptime              {}",
55            format_duration(self.uptime_secs)
56        )?;
57        writeln!(f, "sqlite")?;
58        writeln!(
59            f,
60            "  applied migrations  {} of {} ({})",
61            self.sqlite.applied,
62            self.sqlite.total,
63            format_migrations(&self.sqlite.applied_descriptions)
64        )?;
65        if !self.sqlite.pending_descriptions.is_empty() {
66            writeln!(
67                f,
68                "  pending migrations  {}",
69                format_migrations(&self.sqlite.pending_descriptions)
70            )?;
71        }
72        print_lifecycle_counts(f, &self.lifecycles)?;
73        writeln!(f, "kqueue watchers       {}", self.watchers.kqueue_watchers)?;
74        writeln!(f, "shim sockets          {}", self.watchers.shim_sockets)?;
75        writeln!(f, "event waiters         {}", self.watchers.event_waiters)?;
76        writeln!(f, "launchers")?;
77        for launcher in &self.launchers {
78            let value = launcher
79                .command
80                .as_deref()
81                .or(launcher.error.as_deref())
82                .unwrap_or("unavailable");
83            writeln!(f, "  {:<18} {}", launcher.runtime, value)?;
84        }
85        writeln!(f, "tmux                  {}", format_tmux(self))?;
86        writeln!(
87            f,
88            "last probe sweep      {}",
89            self.last_probe_sweep
90                .map(|time| time.to_rfc3339())
91                .unwrap_or_else(|| "never".to_owned())
92        )?;
93        print_recent_lost(f, self)
94    }
95}
96
97impl CliOutput for Vec<Lifecycle> {
98    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
99        if self.is_empty() {
100            return writeln!(f, "no lifecycles");
101        }
102        for lifecycle in self {
103            writeln!(
104                f,
105                "session_id={} state={} runtime={} shim_pid={} runtime_pid={} start_time={} tmux_pane={}",
106                lifecycle.session_id,
107                lifecycle.state,
108                lifecycle.runtime,
109                display_optional_u32(lifecycle.shim_pid),
110                display_optional_u32(lifecycle.runtime_pid),
111                lifecycle
112                    .start_time
113                    .map(|time| time.to_rfc3339())
114                    .unwrap_or_else(|| "-".to_owned()),
115                lifecycle
116                    .tmux_pane
117                    .as_ref()
118                    .map(ToString::to_string)
119                    .unwrap_or_else(|| "-".to_owned())
120            )?;
121        }
122        Ok(())
123    }
124}
125
126impl CliOutput for Vec<RuntimeEvent> {
127    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
128        for event in self {
129            match event {
130                RuntimeEvent::Running {
131                    session_id,
132                    runtime_pid,
133                    start_time,
134                } => writeln!(
135                    f,
136                    "runtime event=Running session_id={} runtime_pid={} start_time={}",
137                    session_id,
138                    runtime_pid,
139                    start_time.to_rfc3339()
140                )?,
141                RuntimeEvent::Terminated {
142                    session_id,
143                    exit_code,
144                    signal,
145                    evidence,
146                } => writeln!(
147                    f,
148                    "runtime event=Terminated session_id={} exit_code={} signal={} evidence={}",
149                    session_id,
150                    display_optional_i32(*exit_code),
151                    display_optional_i32(*signal),
152                    evidence
153                )?,
154                RuntimeEvent::Lost {
155                    session_id,
156                    evidence,
157                } => writeln!(
158                    f,
159                    "runtime event=Lost session_id={} evidence={}",
160                    session_id, evidence
161                )?,
162            }
163        }
164        Ok(())
165    }
166}
167
168impl CliOutput for PaneSnapshot {
169    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
170        write!(
171            f,
172            "pane snapshot; captured_at_ms={} scrollback_lines_requested={} scrollback_lines_included={} pane_history_lines={}\n{}",
173            self.captured_at_ms,
174            self.scrollback_lines_requested,
175            self.scrollback_lines_included,
176            self.pane_history_lines,
177            self.content
178        )
179    }
180}
181
182impl CliOutput for RuntimeResponse {
183    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
184        match self {
185            Self::Spawned {
186                lifecycle,
187                event,
188                log_dir,
189                stdout_path,
190                stderr_path,
191            } => writeln!(
192                f,
193                "spawn OK; lifecycle state={}; runtime event={}; runtime_pid={} log_dir={} stdout_path={} stderr_path={}",
194                lifecycle.state,
195                event_name(event),
196                lifecycle
197                    .runtime_pid
198                    .expect("running lifecycle runtime pid"),
199                display_optional_path(log_dir.as_deref()),
200                display_optional_path(stdout_path.as_deref()),
201                display_optional_path(stderr_path.as_deref())
202            ),
203            other => write!(f, "unexpected runtime response: {other:?}"),
204        }
205    }
206}
207
208impl CliOutput for KillByPidResponse {
209    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
210        writeln!(
211            f,
212            "kill OK; pid={} signal={} killed_after_grace={}",
213            self.pid, self.signal, self.killed_after_grace
214        )
215    }
216}
217
218impl CliOutput for NudgeResponse {
219    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
220        match self.outcome {
221            NudgeOutcome::Delivered => writeln!(f, "nudge delivered"),
222            NudgeOutcome::Unsupported(reason) => {
223                writeln!(f, "nudge unsupported; reason={}", reason.as_str())
224            }
225            NudgeOutcome::Failed(reason) => {
226                writeln!(f, "nudge failed; reason={}", reason.as_str())
227            }
228        }
229    }
230}
231
232fn print_lifecycle_counts(f: &mut impl Write, counts: &LifecycleCounts) -> fmt::Result {
233    writeln!(f, "lifecycles")?;
234    writeln!(f, "  forking             {}", counts.forking)?;
235    writeln!(f, "  running             {}", counts.running)?;
236    writeln!(f, "  exited              {}", counts.exited)?;
237    writeln!(f, "  lost                {}", counts.lost)
238}
239
240fn print_recent_lost(f: &mut impl Write, doctor: &DoctorResponse) -> fmt::Result {
241    if doctor.recent_lost.is_empty() {
242        return writeln!(f, "recent lost           (none in last 24h)");
243    }
244    writeln!(f, "recent lost")?;
245    for event in &doctor.recent_lost {
246        writeln!(
247            f,
248            "  {} {} {}",
249            event.session_id,
250            event.evidence,
251            event.occurred_at.to_rfc3339()
252        )?;
253    }
254    Ok(())
255}
256
257fn format_migrations(values: &[String]) -> String {
258    if values.is_empty() {
259        return "none".to_owned();
260    }
261    values.join(", ")
262}
263
264fn format_capabilities(values: &[RuntimeCapability]) -> String {
265    if values.is_empty() {
266        return "none".to_owned();
267    }
268    values
269        .iter()
270        .map(|capability| capability.as_str())
271        .collect::<Vec<_>>()
272        .join(", ")
273}
274
275fn format_tmux(doctor: &DoctorResponse) -> String {
276    if doctor.tmux.available {
277        let version = doctor.tmux.version.as_deref().unwrap_or("version unknown");
278        return format!("available ({version})");
279    }
280    match doctor.tmux.error.as_deref() {
281        Some(error) => format!("unavailable ({error})"),
282        None => "unavailable".to_owned(),
283    }
284}
285
286fn format_duration(total_seconds: u64) -> String {
287    let hours = total_seconds / 3600;
288    let minutes = (total_seconds % 3600) / 60;
289    let seconds = total_seconds % 60;
290    format!("{hours:02}:{minutes:02}:{seconds:02}")
291}
292
293fn event_name(event: &RuntimeEvent) -> &'static str {
294    match event {
295        RuntimeEvent::Running { .. } => "Running",
296        RuntimeEvent::Terminated { .. } => "Terminated",
297        RuntimeEvent::Lost { .. } => "Lost",
298    }
299}
300
301fn display_optional_u32(value: Option<u32>) -> String {
302    value
303        .map(|inner| inner.to_string())
304        .unwrap_or_else(|| "-".to_owned())
305}
306
307fn display_optional_i32(value: Option<i32>) -> String {
308    value
309        .map(|inner| inner.to_string())
310        .unwrap_or_else(|| "-".to_owned())
311}
312
313fn display_optional_path(value: Option<&Path>) -> String {
314    value
315        .map(|path| path.display().to_string())
316        .unwrap_or_else(|| "-".to_owned())
317}