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}