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!(
74            f,
75            "process exit watchers {}",
76            self.watchers.process_exit_watchers
77        )?;
78        writeln!(f, "shim sockets          {}", self.watchers.shim_sockets)?;
79        writeln!(f, "event waiters         {}", self.watchers.event_waiters)?;
80        writeln!(f, "launchers")?;
81        for launcher in &self.launchers {
82            let value = launcher
83                .command
84                .as_deref()
85                .or(launcher.error.as_deref())
86                .unwrap_or("unavailable");
87            writeln!(f, "  {:<18} {}", launcher.runtime, value)?;
88        }
89        writeln!(f, "tmux                  {}", format_tmux(self))?;
90        writeln!(
91            f,
92            "last probe sweep      {}",
93            self.last_probe_sweep
94                .map(|time| time.to_rfc3339())
95                .unwrap_or_else(|| "never".to_owned())
96        )?;
97        print_recent_lost(f, self)
98    }
99}
100
101impl CliOutput for Vec<Lifecycle> {
102    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
103        if self.is_empty() {
104            return writeln!(f, "no lifecycles");
105        }
106        for lifecycle in self {
107            writeln!(
108                f,
109                "session_id={} state={} runtime={} shim_pid={} runtime_pid={} start_time={} tmux_pane={}",
110                lifecycle.session_id,
111                lifecycle.state,
112                lifecycle.runtime,
113                display_optional_u32(lifecycle.shim_pid),
114                display_optional_u32(lifecycle.runtime_pid),
115                lifecycle
116                    .start_time
117                    .map(|time| time.to_rfc3339())
118                    .unwrap_or_else(|| "-".to_owned()),
119                lifecycle
120                    .tmux_pane
121                    .as_ref()
122                    .map(ToString::to_string)
123                    .unwrap_or_else(|| "-".to_owned())
124            )?;
125        }
126        Ok(())
127    }
128}
129
130impl CliOutput for Vec<RuntimeEvent> {
131    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
132        for event in self {
133            match event {
134                RuntimeEvent::Running {
135                    session_id,
136                    runtime_pid,
137                    start_time,
138                } => writeln!(
139                    f,
140                    "runtime event=Running session_id={} runtime_pid={} start_time={}",
141                    session_id,
142                    runtime_pid,
143                    start_time.to_rfc3339()
144                )?,
145                RuntimeEvent::Terminated {
146                    session_id,
147                    exit_code,
148                    signal,
149                    evidence,
150                } => writeln!(
151                    f,
152                    "runtime event=Terminated session_id={} exit_code={} signal={} evidence={}",
153                    session_id,
154                    display_optional_i32(*exit_code),
155                    display_optional_i32(*signal),
156                    evidence
157                )?,
158                RuntimeEvent::Lost {
159                    session_id,
160                    evidence,
161                } => writeln!(
162                    f,
163                    "runtime event=Lost session_id={} evidence={}",
164                    session_id, evidence
165                )?,
166            }
167        }
168        Ok(())
169    }
170}
171
172impl CliOutput for PaneSnapshot {
173    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
174        write!(
175            f,
176            "pane snapshot; captured_at_ms={} scrollback_lines_requested={} scrollback_lines_included={} pane_history_lines={}\n{}",
177            self.captured_at_ms,
178            self.scrollback_lines_requested,
179            self.scrollback_lines_included,
180            self.pane_history_lines,
181            self.content
182        )
183    }
184}
185
186impl CliOutput for RuntimeResponse {
187    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
188        match self {
189            Self::Spawned {
190                lifecycle,
191                event,
192                log_dir,
193                stdout_path,
194                stderr_path,
195            } => writeln!(
196                f,
197                "spawn OK; lifecycle state={}; runtime event={}; runtime_pid={} log_dir={} stdout_path={} stderr_path={}",
198                lifecycle.state,
199                event_name(event),
200                lifecycle
201                    .runtime_pid
202                    .expect("running lifecycle runtime pid"),
203                display_optional_path(log_dir.as_deref()),
204                display_optional_path(stdout_path.as_deref()),
205                display_optional_path(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 NudgeResponse {
223    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
224        match self.outcome {
225            NudgeOutcome::Delivered => writeln!(f, "nudge delivered"),
226            NudgeOutcome::Unsupported(reason) => {
227                writeln!(f, "nudge unsupported; reason={}", reason.as_str())
228            }
229            NudgeOutcome::Failed(reason) => {
230                writeln!(f, "nudge failed; reason={}", reason.as_str())
231            }
232        }
233    }
234}
235
236fn print_lifecycle_counts(f: &mut impl Write, counts: &LifecycleCounts) -> fmt::Result {
237    writeln!(f, "lifecycles")?;
238    writeln!(f, "  forking             {}", counts.forking)?;
239    writeln!(f, "  running             {}", counts.running)?;
240    writeln!(f, "  exited              {}", counts.exited)?;
241    writeln!(f, "  lost                {}", counts.lost)
242}
243
244fn print_recent_lost(f: &mut impl Write, doctor: &DoctorResponse) -> fmt::Result {
245    if doctor.recent_lost.is_empty() {
246        return writeln!(f, "recent lost           (none in last 24h)");
247    }
248    writeln!(f, "recent lost")?;
249    for event in &doctor.recent_lost {
250        writeln!(
251            f,
252            "  {} {} {}",
253            event.session_id,
254            event.evidence,
255            event.occurred_at.to_rfc3339()
256        )?;
257    }
258    Ok(())
259}
260
261fn format_migrations(values: &[String]) -> String {
262    if values.is_empty() {
263        return "none".to_owned();
264    }
265    values.join(", ")
266}
267
268fn format_capabilities(values: &[RuntimeCapability]) -> String {
269    if values.is_empty() {
270        return "none".to_owned();
271    }
272    values
273        .iter()
274        .map(|capability| capability.as_str())
275        .collect::<Vec<_>>()
276        .join(", ")
277}
278
279fn format_tmux(doctor: &DoctorResponse) -> String {
280    if doctor.tmux.available {
281        let version = doctor.tmux.version.as_deref().unwrap_or("version unknown");
282        return format!("available ({version})");
283    }
284    match doctor.tmux.error.as_deref() {
285        Some(error) => format!("unavailable ({error})"),
286        None => "unavailable".to_owned(),
287    }
288}
289
290fn format_duration(total_seconds: u64) -> String {
291    let hours = total_seconds / 3600;
292    let minutes = (total_seconds % 3600) / 60;
293    let seconds = total_seconds % 60;
294    format!("{hours:02}:{minutes:02}:{seconds:02}")
295}
296
297fn event_name(event: &RuntimeEvent) -> &'static str {
298    match event {
299        RuntimeEvent::Running { .. } => "Running",
300        RuntimeEvent::Terminated { .. } => "Terminated",
301        RuntimeEvent::Lost { .. } => "Lost",
302    }
303}
304
305fn display_optional_u32(value: Option<u32>) -> String {
306    value
307        .map(|inner| inner.to_string())
308        .unwrap_or_else(|| "-".to_owned())
309}
310
311fn display_optional_i32(value: Option<i32>) -> String {
312    value
313        .map(|inner| inner.to_string())
314        .unwrap_or_else(|| "-".to_owned())
315}
316
317fn display_optional_path(value: Option<&Path>) -> String {
318    value
319        .map(|path| path.display().to_string())
320        .unwrap_or_else(|| "-".to_owned())
321}