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, NudgeOutcome, NudgeResponse, PaneSnapshot, RuntimeCapability, RuntimeEvent,
10    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={}",
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            )?;
126        }
127        Ok(())
128    }
129}
130
131impl CliOutput for Vec<RuntimeEvent> {
132    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
133        for event in self {
134            match event {
135                RuntimeEvent::Running {
136                    session_id,
137                    runtime_pid,
138                    start_time,
139                } => writeln!(
140                    f,
141                    "runtime event=Running session_id={} runtime_pid={} start_time={}",
142                    session_id,
143                    runtime_pid,
144                    start_time.to_rfc3339()
145                )?,
146                RuntimeEvent::Terminated {
147                    session_id,
148                    exit_code,
149                    signal,
150                    evidence,
151                } => writeln!(
152                    f,
153                    "runtime event=Terminated session_id={} exit_code={} signal={} evidence={}",
154                    session_id,
155                    display_optional_i32(*exit_code),
156                    display_optional_i32(*signal),
157                    evidence
158                )?,
159                RuntimeEvent::Lost {
160                    session_id,
161                    evidence,
162                } => writeln!(
163                    f,
164                    "runtime event=Lost session_id={} evidence={}",
165                    session_id, evidence
166                )?,
167            }
168        }
169        Ok(())
170    }
171}
172
173impl CliOutput for EventsPayload {
174    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
175        self.events.render_human(f)?;
176        writeln!(f, "cursor: {}", self.cursor)
177    }
178}
179
180impl CliOutput for PaneSnapshot {
181    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
182        write!(
183            f,
184            "pane snapshot; captured_at_ms={} scrollback_lines_requested={} scrollback_lines_included={} pane_history_lines={}\n{}",
185            self.captured_at_ms,
186            self.scrollback_lines_requested,
187            self.scrollback_lines_included,
188            self.pane_history_lines,
189            self.content
190        )
191    }
192}
193
194impl CliOutput for RuntimeResponse {
195    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
196        match self {
197            Self::Spawned(payload) => writeln!(
198                f,
199                "spawn OK; lifecycle state={}; runtime event={}; runtime_pid={} log_dir={} stdout_path={} stderr_path={}",
200                payload.lifecycle.state,
201                event_name(&payload.event),
202                display_optional_u32(payload.lifecycle.runtime_pid),
203                display_optional_path(payload.log_dir.as_deref()),
204                display_optional_path(payload.stdout_path.as_deref()),
205                display_optional_path(payload.stderr_path.as_deref())
206            ),
207            other => write!(f, "unexpected runtime response: {other:?}"),
208        }
209    }
210}
211
212impl CliOutput for KillByPidResponse {
213    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
214        writeln!(
215            f,
216            "kill OK; pid={} signal={} killed_after_grace={}",
217            self.pid, self.signal, self.killed_after_grace
218        )
219    }
220}
221
222impl CliOutput for KillOutcome {
223    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
224        match self {
225            Self::Signalled => writeln!(f, "signalled"),
226            Self::AlreadyExited => writeln!(f, "already exited"),
227        }
228    }
229}
230
231impl CliOutput for KilledPayload {
232    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
233        self.outcome.render_human(f)
234    }
235}
236
237impl CliOutput for NudgeResponse {
238    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
239        match self.outcome {
240            NudgeOutcome::Delivered => writeln!(f, "nudge delivered"),
241            NudgeOutcome::Unsupported(reason) => {
242                writeln!(f, "nudge unsupported; reason={}", reason.as_str())
243            }
244            NudgeOutcome::Failed(reason) => {
245                writeln!(f, "nudge failed; reason={}", reason.as_str())
246            }
247        }
248    }
249}
250
251fn print_lifecycle_counts(f: &mut impl Write, counts: &LifecycleCounts) -> fmt::Result {
252    writeln!(f, "lifecycles")?;
253    writeln!(f, "  forking             {}", counts.forking)?;
254    writeln!(f, "  running             {}", counts.running)?;
255    writeln!(f, "  exited              {}", counts.exited)?;
256    writeln!(f, "  lost                {}", counts.lost)
257}
258
259fn print_recent_lost(f: &mut impl Write, doctor: &DoctorResponse) -> fmt::Result {
260    if doctor.recent_lost.is_empty() {
261        return writeln!(f, "recent lost           (none in last 24h)");
262    }
263    writeln!(f, "recent lost")?;
264    for event in &doctor.recent_lost {
265        writeln!(
266            f,
267            "  {} {} {}",
268            event.session_id,
269            event.evidence,
270            event.occurred_at.to_rfc3339()
271        )?;
272    }
273    Ok(())
274}
275
276fn format_migrations(values: &[String]) -> String {
277    if values.is_empty() {
278        return "none".to_owned();
279    }
280    values.join(", ")
281}
282
283fn format_capabilities(values: &[RuntimeCapability]) -> String {
284    if values.is_empty() {
285        return "none".to_owned();
286    }
287    values
288        .iter()
289        .map(|capability| capability.as_str())
290        .collect::<Vec<_>>()
291        .join(", ")
292}
293
294fn format_tmux(doctor: &DoctorResponse) -> String {
295    if doctor.tmux.available {
296        let version = doctor.tmux.version.as_deref().unwrap_or("version unknown");
297        return format!("available ({version})");
298    }
299    match doctor.tmux.error.as_deref() {
300        Some(error) => format!("unavailable ({error})"),
301        None => "unavailable".to_owned(),
302    }
303}
304
305fn format_duration(total_seconds: u64) -> String {
306    let hours = total_seconds / 3600;
307    let minutes = (total_seconds % 3600) / 60;
308    let seconds = total_seconds % 60;
309    format!("{hours:02}:{minutes:02}:{seconds:02}")
310}
311
312fn event_name(event: &RuntimeEvent) -> &'static str {
313    match event {
314        RuntimeEvent::Running { .. } => "Running",
315        RuntimeEvent::Terminated { .. } => "Terminated",
316        RuntimeEvent::Lost { .. } => "Lost",
317    }
318}
319
320fn display_optional_u32(value: Option<u32>) -> String {
321    value
322        .map(|inner| inner.to_string())
323        .unwrap_or_else(|| "-".to_owned())
324}
325
326fn display_optional_i32(value: Option<i32>) -> String {
327    value
328        .map(|inner| inner.to_string())
329        .unwrap_or_else(|| "-".to_owned())
330}
331
332fn display_optional_path(value: Option<&Path>) -> String {
333    value
334        .map(|path| path.display().to_string())
335        .unwrap_or_else(|| "-".to_owned())
336}