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!(f, "kqueue watchers {}", self.watchers.kqueue_watchers)?;
74 writeln!(f, "shim sockets {}", self.watchers.shim_sockets)?;
75 writeln!(f, "event waiters {}", self.watchers.event_waiters)?;
76 writeln!(f, "launchers")?;
77 for launcher in &self.launchers {
78 let value = launcher
79 .command
80 .as_deref()
81 .or(launcher.error.as_deref())
82 .unwrap_or("unavailable");
83 writeln!(f, " {:<18} {}", launcher.runtime, value)?;
84 }
85 writeln!(f, "tmux {}", format_tmux(self))?;
86 writeln!(
87 f,
88 "last probe sweep {}",
89 self.last_probe_sweep
90 .map(|time| time.to_rfc3339())
91 .unwrap_or_else(|| "never".to_owned())
92 )?;
93 print_recent_lost(f, self)
94 }
95}
96
97impl CliOutput for Vec<Lifecycle> {
98 fn render_human(&self, f: &mut impl Write) -> fmt::Result {
99 if self.is_empty() {
100 return writeln!(f, "no lifecycles");
101 }
102 for lifecycle in self {
103 writeln!(
104 f,
105 "session_id={} state={} runtime={} shim_pid={} runtime_pid={} start_time={} tmux_pane={}",
106 lifecycle.session_id,
107 lifecycle.state,
108 lifecycle.runtime,
109 display_optional_u32(lifecycle.shim_pid),
110 display_optional_u32(lifecycle.runtime_pid),
111 lifecycle
112 .start_time
113 .map(|time| time.to_rfc3339())
114 .unwrap_or_else(|| "-".to_owned()),
115 lifecycle
116 .tmux_pane
117 .as_ref()
118 .map(ToString::to_string)
119 .unwrap_or_else(|| "-".to_owned())
120 )?;
121 }
122 Ok(())
123 }
124}
125
126impl CliOutput for Vec<RuntimeEvent> {
127 fn render_human(&self, f: &mut impl Write) -> fmt::Result {
128 for event in self {
129 match event {
130 RuntimeEvent::Running {
131 session_id,
132 runtime_pid,
133 start_time,
134 } => writeln!(
135 f,
136 "runtime event=Running session_id={} runtime_pid={} start_time={}",
137 session_id,
138 runtime_pid,
139 start_time.to_rfc3339()
140 )?,
141 RuntimeEvent::Terminated {
142 session_id,
143 exit_code,
144 signal,
145 evidence,
146 } => writeln!(
147 f,
148 "runtime event=Terminated session_id={} exit_code={} signal={} evidence={}",
149 session_id,
150 display_optional_i32(*exit_code),
151 display_optional_i32(*signal),
152 evidence
153 )?,
154 RuntimeEvent::Lost {
155 session_id,
156 evidence,
157 } => writeln!(
158 f,
159 "runtime event=Lost session_id={} evidence={}",
160 session_id, evidence
161 )?,
162 }
163 }
164 Ok(())
165 }
166}
167
168impl CliOutput for PaneSnapshot {
169 fn render_human(&self, f: &mut impl Write) -> fmt::Result {
170 write!(
171 f,
172 "pane snapshot; captured_at_ms={} scrollback_lines_requested={} scrollback_lines_included={} pane_history_lines={}\n{}",
173 self.captured_at_ms,
174 self.scrollback_lines_requested,
175 self.scrollback_lines_included,
176 self.pane_history_lines,
177 self.content
178 )
179 }
180}
181
182impl CliOutput for RuntimeResponse {
183 fn render_human(&self, f: &mut impl Write) -> fmt::Result {
184 match self {
185 Self::Spawned {
186 lifecycle,
187 event,
188 log_dir,
189 stdout_path,
190 stderr_path,
191 } => writeln!(
192 f,
193 "spawn OK; lifecycle state={}; runtime event={}; runtime_pid={} log_dir={} stdout_path={} stderr_path={}",
194 lifecycle.state,
195 event_name(event),
196 lifecycle
197 .runtime_pid
198 .expect("running lifecycle runtime pid"),
199 display_optional_path(log_dir.as_deref()),
200 display_optional_path(stdout_path.as_deref()),
201 display_optional_path(stderr_path.as_deref())
202 ),
203 other => write!(f, "unexpected runtime response: {other:?}"),
204 }
205 }
206}
207
208impl CliOutput for KillByPidResponse {
209 fn render_human(&self, f: &mut impl Write) -> fmt::Result {
210 writeln!(
211 f,
212 "kill OK; pid={} signal={} killed_after_grace={}",
213 self.pid, self.signal, self.killed_after_grace
214 )
215 }
216}
217
218impl CliOutput for NudgeResponse {
219 fn render_human(&self, f: &mut impl Write) -> fmt::Result {
220 match self.outcome {
221 NudgeOutcome::Delivered => writeln!(f, "nudge delivered"),
222 NudgeOutcome::Unsupported(reason) => {
223 writeln!(f, "nudge unsupported; reason={}", reason.as_str())
224 }
225 NudgeOutcome::Failed(reason) => {
226 writeln!(f, "nudge failed; reason={}", reason.as_str())
227 }
228 }
229 }
230}
231
232fn print_lifecycle_counts(f: &mut impl Write, counts: &LifecycleCounts) -> fmt::Result {
233 writeln!(f, "lifecycles")?;
234 writeln!(f, " forking {}", counts.forking)?;
235 writeln!(f, " running {}", counts.running)?;
236 writeln!(f, " exited {}", counts.exited)?;
237 writeln!(f, " lost {}", counts.lost)
238}
239
240fn print_recent_lost(f: &mut impl Write, doctor: &DoctorResponse) -> fmt::Result {
241 if doctor.recent_lost.is_empty() {
242 return writeln!(f, "recent lost (none in last 24h)");
243 }
244 writeln!(f, "recent lost")?;
245 for event in &doctor.recent_lost {
246 writeln!(
247 f,
248 " {} {} {}",
249 event.session_id,
250 event.evidence,
251 event.occurred_at.to_rfc3339()
252 )?;
253 }
254 Ok(())
255}
256
257fn format_migrations(values: &[String]) -> String {
258 if values.is_empty() {
259 return "none".to_owned();
260 }
261 values.join(", ")
262}
263
264fn format_capabilities(values: &[RuntimeCapability]) -> String {
265 if values.is_empty() {
266 return "none".to_owned();
267 }
268 values
269 .iter()
270 .map(|capability| capability.as_str())
271 .collect::<Vec<_>>()
272 .join(", ")
273}
274
275fn format_tmux(doctor: &DoctorResponse) -> String {
276 if doctor.tmux.available {
277 let version = doctor.tmux.version.as_deref().unwrap_or("version unknown");
278 return format!("available ({version})");
279 }
280 match doctor.tmux.error.as_deref() {
281 Some(error) => format!("unavailable ({error})"),
282 None => "unavailable".to_owned(),
283 }
284}
285
286fn format_duration(total_seconds: u64) -> String {
287 let hours = total_seconds / 3600;
288 let minutes = (total_seconds % 3600) / 60;
289 let seconds = total_seconds % 60;
290 format!("{hours:02}:{minutes:02}:{seconds:02}")
291}
292
293fn event_name(event: &RuntimeEvent) -> &'static str {
294 match event {
295 RuntimeEvent::Running { .. } => "Running",
296 RuntimeEvent::Terminated { .. } => "Terminated",
297 RuntimeEvent::Lost { .. } => "Lost",
298 }
299}
300
301fn display_optional_u32(value: Option<u32>) -> String {
302 value
303 .map(|inner| inner.to_string())
304 .unwrap_or_else(|| "-".to_owned())
305}
306
307fn display_optional_i32(value: Option<i32>) -> String {
308 value
309 .map(|inner| inner.to_string())
310 .unwrap_or_else(|| "-".to_owned())
311}
312
313fn display_optional_path(value: Option<&Path>) -> String {
314 value
315 .map(|path| path.display().to_string())
316 .unwrap_or_else(|| "-".to_owned())
317}