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