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}