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}