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!(f, "docker")?;
92        writeln!(
93            f,
94            "  cli                 {}",
95            format_readiness(&self.docker.cli)
96        )?;
97        writeln!(
98            f,
99            "  daemon              {}",
100            format_readiness(&self.docker.daemon)
101        )?;
102        writeln!(
103            f,
104            "  manifest validation {}",
105            format_readiness(&self.docker.manifest_validation)
106        )?;
107        writeln!(
108            f,
109            "  isolation           supported={} workspace={} experimental={}",
110            self.docker.isolation.supported,
111            self.docker.isolation.default_workspace,
112            self.docker.isolation.experimental
113        )?;
114        writeln!(
115            f,
116            "last probe sweep      {}",
117            self.last_probe_sweep
118                .map(|time| time.to_rfc3339())
119                .unwrap_or_else(|| "never".to_owned())
120        )?;
121        print_recent_lost(f, self)
122    }
123}
124
125impl CliOutput for Vec<Lifecycle> {
126    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
127        if self.is_empty() {
128            return writeln!(f, "no lifecycles");
129        }
130        for lifecycle in self {
131            writeln!(
132                f,
133                "session_id={} state={} runtime={} shim_pid={} runtime_pid={} start_time={} tmux_pane={} log_availability={}",
134                lifecycle.session_id,
135                lifecycle.state,
136                lifecycle.runtime,
137                display_optional_u32(lifecycle.shim_pid),
138                display_optional_u32(lifecycle.runtime_pid),
139                lifecycle
140                    .start_time
141                    .map(|time| time.to_rfc3339())
142                    .unwrap_or_else(|| "-".to_owned()),
143                lifecycle
144                    .tmux_pane
145                    .as_ref()
146                    .map(ToString::to_string)
147                    .unwrap_or_else(|| "-".to_owned()),
148                format_log_availability(lifecycle.log_availability.as_ref())
149            )?;
150        }
151        Ok(())
152    }
153}
154
155impl CliOutput for Vec<RuntimeEvent> {
156    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
157        for event in self {
158            match event {
159                RuntimeEvent::Running {
160                    session_id,
161                    runtime_pid,
162                    start_time,
163                } => writeln!(
164                    f,
165                    "runtime event=Running session_id={} runtime_pid={} start_time={}",
166                    session_id,
167                    runtime_pid,
168                    start_time.to_rfc3339()
169                )?,
170                RuntimeEvent::Terminated {
171                    session_id,
172                    exit_code,
173                    signal,
174                    evidence,
175                } => writeln!(
176                    f,
177                    "runtime event=Terminated session_id={} exit_code={} signal={} evidence={}",
178                    session_id,
179                    display_optional_i32(*exit_code),
180                    display_optional_i32(*signal),
181                    evidence
182                )?,
183                RuntimeEvent::Lost {
184                    session_id,
185                    evidence,
186                } => writeln!(
187                    f,
188                    "runtime event=Lost session_id={} evidence={}",
189                    session_id, evidence
190                )?,
191            }
192        }
193        Ok(())
194    }
195}
196
197impl CliOutput for EventsPayload {
198    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
199        self.events.render_human(f)?;
200        writeln!(f, "cursor: {}", self.cursor)
201    }
202}
203
204impl CliOutput for PaneSnapshot {
205    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
206        write!(
207            f,
208            "pane snapshot; captured_at_ms={} scrollback_lines_requested={} scrollback_lines_included={} pane_history_lines={}\n{}",
209            self.captured_at_ms,
210            self.scrollback_lines_requested,
211            self.scrollback_lines_included,
212            self.pane_history_lines,
213            self.content
214        )
215    }
216}
217
218impl CliOutput for RuntimeResponse {
219    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
220        match self {
221            Self::Spawned(payload) => writeln!(
222                f,
223                "spawn OK; lifecycle state={}; runtime event={}; runtime_pid={} log_dir={} stdout_path={} stderr_path={}",
224                payload.lifecycle.state,
225                event_name(&payload.event),
226                display_optional_u32(payload.lifecycle.runtime_pid),
227                display_optional_path(payload.log_dir.as_deref()),
228                display_optional_path(payload.stdout_path.as_deref()),
229                display_optional_path(payload.stderr_path.as_deref())
230            ),
231            other => write!(f, "unexpected runtime response: {other:?}"),
232        }
233    }
234}
235
236impl CliOutput for KillByPidResponse {
237    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
238        writeln!(
239            f,
240            "kill OK; pid={} signal={} killed_after_grace={}",
241            self.pid, self.signal, self.killed_after_grace
242        )
243    }
244}
245
246impl CliOutput for KillOutcome {
247    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
248        match self {
249            Self::Signalled => writeln!(f, "signalled"),
250            Self::AlreadyExited => writeln!(f, "already exited"),
251        }
252    }
253}
254
255impl CliOutput for KilledPayload {
256    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
257        self.outcome.render_human(f)
258    }
259}
260
261impl CliOutput for NudgeResponse {
262    fn render_human(&self, f: &mut impl Write) -> fmt::Result {
263        match self.outcome {
264            NudgeOutcome::Delivered => writeln!(f, "nudge delivered"),
265            NudgeOutcome::Unsupported(reason) => {
266                writeln!(f, "nudge unsupported; reason={}", reason.as_str())
267            }
268            NudgeOutcome::Failed(reason) => {
269                writeln!(f, "nudge failed; reason={}", reason.as_str())
270            }
271        }
272    }
273}
274
275fn print_lifecycle_counts(f: &mut impl Write, counts: &LifecycleCounts) -> fmt::Result {
276    writeln!(f, "lifecycles")?;
277    writeln!(f, "  forking             {}", counts.forking)?;
278    writeln!(f, "  running             {}", counts.running)?;
279    writeln!(f, "  exited              {}", counts.exited)?;
280    writeln!(f, "  lost                {}", counts.lost)
281}
282
283fn print_recent_lost(f: &mut impl Write, doctor: &DoctorResponse) -> fmt::Result {
284    if doctor.recent_lost.is_empty() {
285        return writeln!(f, "recent lost           (none in last 24h)");
286    }
287    writeln!(f, "recent lost")?;
288    for event in &doctor.recent_lost {
289        writeln!(
290            f,
291            "  {} {} {}",
292            event.session_id,
293            event.evidence,
294            event.occurred_at.to_rfc3339()
295        )?;
296    }
297    Ok(())
298}
299
300fn format_migrations(values: &[String]) -> String {
301    if values.is_empty() {
302        return "none".to_owned();
303    }
304    values.join(", ")
305}
306
307fn format_capabilities(values: &[RuntimeCapability]) -> String {
308    if values.is_empty() {
309        return "none".to_owned();
310    }
311    values
312        .iter()
313        .map(|capability| capability.as_str())
314        .collect::<Vec<_>>()
315        .join(", ")
316}
317
318fn format_tmux(doctor: &DoctorResponse) -> String {
319    if doctor.tmux.available {
320        let version = doctor.tmux.version.as_deref().unwrap_or("version unknown");
321        return format!("available ({version})");
322    }
323    match doctor.tmux.error.as_deref() {
324        Some(error) => format!("unavailable ({error})"),
325        None => "unavailable".to_owned(),
326    }
327}
328
329fn format_readiness(readiness: &crate::DockerReadiness) -> String {
330    if readiness.ready {
331        return readiness
332            .detail
333            .as_deref()
334            .map(|detail| format!("ready ({detail})"))
335            .unwrap_or_else(|| "ready".to_owned());
336    }
337    readiness
338        .error
339        .as_deref()
340        .map(|error| format!("unavailable ({error})"))
341        .unwrap_or_else(|| "unavailable".to_owned())
342}
343
344fn format_log_availability(value: Option<&LogAvailability>) -> String {
345    match value {
346        Some(LogAvailability::Headless { .. }) => "headless".to_owned(),
347        Some(LogAvailability::TmuxPaneSnapshot) => "tmux_pane_snapshot".to_owned(),
348        Some(LogAvailability::Unavailable { reason }) => format!("unavailable:{}", reason.as_str()),
349        None => "-".to_owned(),
350    }
351}
352
353fn format_duration(total_seconds: u64) -> String {
354    let hours = total_seconds / 3600;
355    let minutes = (total_seconds % 3600) / 60;
356    let seconds = total_seconds % 60;
357    format!("{hours:02}:{minutes:02}:{seconds:02}")
358}
359
360fn event_name(event: &RuntimeEvent) -> &'static str {
361    match event {
362        RuntimeEvent::Running { .. } => "Running",
363        RuntimeEvent::Terminated { .. } => "Terminated",
364        RuntimeEvent::Lost { .. } => "Lost",
365    }
366}
367
368fn display_optional_u32(value: Option<u32>) -> String {
369    value
370        .map(|inner| inner.to_string())
371        .unwrap_or_else(|| "-".to_owned())
372}
373
374fn display_optional_i32(value: Option<i32>) -> String {
375    value
376        .map(|inner| inner.to_string())
377        .unwrap_or_else(|| "-".to_owned())
378}
379
380fn display_optional_path(value: Option<&Path>) -> String {
381    value
382        .map(|path| path.display().to_string())
383        .unwrap_or_else(|| "-".to_owned())
384}