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}