1use std::fmt::Write;
7
8use serde_json::Value;
9
10use crate::protocol::{ErrorBody, ResponseEnvelope};
11
12pub fn render(verb: Option<&str>, response: &ResponseEnvelope) -> String {
16 if let Some(error) = &response.error {
17 return render_error(error);
18 }
19 let data = response.data.as_ref().unwrap_or(&Value::Null);
20 if data["implemented"] == Value::Bool(false) {
21 let verb = data["verb"].as_str().unwrap_or("this verb");
22 return format!("{verb} is not implemented by the daemon yet\n");
23 }
24 match verb {
25 Some("daemon") => render_daemon(data),
26 Some("init") => render_init(data),
27 Some("post") => render_post(data),
28 Some("run") => render_run(data),
29 Some("retry" | "hold" | "ready") => render_ticket_transition(data),
30 Some("list") => render_list(data),
31 Some("status") => render_status(data),
32 Some("pause" | "resume") => render_scheduler_transition(data),
33 Some("stop") => render_stop(data),
34 Some("wait") => render_wait(data),
35 Some("cancel") => render_cancel(data),
36 Some("logs") => render_logs(data),
37 Some("show") => render_show(data),
38 _ => fallback(data),
39 }
40}
41
42pub fn render_error(error: &ErrorBody) -> String {
43 let code = serde_json::to_value(error.code)
44 .ok()
45 .and_then(|value| value.as_str().map(str::to_owned))
46 .unwrap_or_else(|| "error".into());
47 let mut text = format!("error ({code}): {}\n", error.message);
48 if error.details.as_object().is_some_and(|map| !map.is_empty()) {
49 let _ = writeln!(text, " details: {}", error.details);
50 }
51 text
52}
53
54fn render_daemon(data: &Value) -> String {
55 let pid = &data["pid"];
56 let state = if data["started"] == Value::Bool(true) {
57 "started"
58 } else {
59 "running"
60 };
61 let mut text = format!("daemon {state} (pid {pid})\n");
62 if let Some(socket) = data["socket"].as_str() {
63 let _ = writeln!(text, "socket: {socket}");
64 }
65 if let Some(log) = data["log"].as_str() {
66 let _ = writeln!(text, "log: {log}");
67 }
68 text
69}
70
71fn render_init(data: &Value) -> String {
72 let mut text = format!(
73 "initialized sloop in {}\n",
74 data["repository_root"].as_str().unwrap_or("?")
75 );
76 for (label, key) in [("created", "created"), ("existing", "existing")] {
77 for path in string_items(&data[key]) {
78 let _ = writeln!(text, " {label}: {path}");
79 }
80 }
81 text
82}
83
84fn render_post(data: &Value) -> String {
85 let ticket = &data["ticket"];
86 let mut text = format!(
87 "ticket {} registered from {} (project {}, {})\n",
88 ticket["id"].as_str().unwrap_or("?"),
89 ticket["file"].as_str().unwrap_or("?"),
90 ticket["project"].as_str().unwrap_or("?"),
91 ticket["state"].as_str().unwrap_or("?"),
92 );
93 text.push_str(&render_activation(&data["activation"]));
94 text
95}
96
97fn render_run(data: &Value) -> String {
98 render_activation(&data["activation"])
99}
100
101fn render_activation(activation: &Value) -> String {
102 let Some(fields) = activation.as_object() else {
103 return String::new();
104 };
105 let mut text = format!(
106 "activation {} {} ({}",
107 fields.get("id").and_then(Value::as_str).unwrap_or("?"),
108 fields
109 .get("state")
110 .and_then(Value::as_str)
111 .unwrap_or("queued"),
112 fields.get("kind").and_then(Value::as_str).unwrap_or("?"),
113 );
114 for key in ["ticket", "project", "time"] {
115 if let Some(value) = fields.get(key).and_then(Value::as_str) {
116 let _ = write!(text, ", {key} {value}");
117 }
118 }
119 text.push_str(")\n");
120 text
121}
122
123fn render_status(data: &Value) -> String {
124 let daemon = &data["daemon"];
125 let paused = if daemon["paused"] == Value::Bool(true) {
126 ", paused"
127 } else {
128 ""
129 };
130 let mut text = format!("daemon: pid {}{paused}\n", daemon["pid"]);
131 let _ = writeln!(
132 text,
133 "agents: {} active of {} max",
134 data["gate"]["active_agents"], data["gate"]["max_agents"]
135 );
136 if let Some(hours) = data["gate"]["running_hours"].as_object() {
137 let state = if hours.get("open") == Some(&Value::Bool(true)) {
138 "open"
139 } else {
140 "closed"
141 };
142 let _ = writeln!(
143 text,
144 "running hours: {}-{} ({state})",
145 hours.get("start").and_then(Value::as_str).unwrap_or("?"),
146 hours.get("end").and_then(Value::as_str).unwrap_or("?"),
147 );
148 }
149 if let Some(next_wake) = data["next_wake"].as_str() {
150 let _ = writeln!(text, "next wake: {next_wake}");
151 }
152
153 let tickets = &data["tickets"];
154 let counts: Vec<String> = [
155 "ready",
156 "held",
157 "blocked",
158 "claimed",
159 "merged",
160 "failed",
161 "needs_review",
162 ]
163 .iter()
164 .map(|state| format!("{} {state}", tickets[*state]))
165 .collect();
166 let _ = writeln!(text, "tickets: {}", counts.join(", "));
167
168 for (title, items) in [
169 ("runs", &data["runs"]),
170 ("queued", &data["queued_activations"]),
171 ] {
172 let items = items.as_array().map(Vec::as_slice).unwrap_or_default();
173 if items.is_empty() {
174 let _ = writeln!(text, "{title}: none");
175 continue;
176 }
177 let _ = writeln!(text, "{title}:");
178 for item in items {
179 let _ = writeln!(
180 text,
181 " {} {} (ticket {}, project {})",
182 item["id"].as_str().unwrap_or("?"),
183 item["state"].as_str().unwrap_or("?"),
184 item["ticket"].as_str().unwrap_or("-"),
185 item["project"].as_str().unwrap_or("-"),
186 );
187 }
188 }
189 text
190}
191
192fn render_list(data: &Value) -> String {
193 let tickets = data["tickets"]
194 .as_array()
195 .map(Vec::as_slice)
196 .unwrap_or_default();
197 if tickets.is_empty() {
198 return "no tickets\n".into();
199 }
200
201 let id_width = tickets
202 .iter()
203 .filter_map(|ticket| ticket["id"].as_str())
204 .map(str::len)
205 .max()
206 .unwrap_or(1);
207 let state_width = tickets
208 .iter()
209 .filter_map(|ticket| ticket["state"].as_str())
210 .map(str::len)
211 .max()
212 .unwrap_or(1);
213 let project_width = tickets
214 .iter()
215 .filter_map(|ticket| ticket["project"].as_str())
216 .map(str::len)
217 .max()
218 .unwrap_or(1);
219
220 let mut text = String::new();
221 for ticket in tickets {
222 let id = ticket["id"].as_str().unwrap_or("?");
223 let state = ticket["state"].as_str().unwrap_or("?");
224 let project = ticket["project"].as_str().unwrap_or("?");
225 let _ = write!(text, "{id:id_width$} {state:state_width$} ({project})",);
226 let terminal = matches!(state, "merged" | "needs_review");
227 if ticket["run"].is_null()
228 && !terminal
229 && let Some(reason) = ticket["reason"].as_str()
230 {
231 text.push_str(&" ".repeat(project_width.saturating_sub(project.len())));
232 let _ = write!(text, " — {reason}");
233 }
234 text.push('\n');
235 }
236 text
237}
238
239fn render_ticket_transition(data: &Value) -> String {
240 format!(
241 "ticket {}: {} -> {}\n",
242 data["ticket"].as_str().unwrap_or("?"),
243 data["previous_state"].as_str().unwrap_or("?"),
244 data["state"].as_str().unwrap_or("?"),
245 )
246}
247
248fn render_scheduler_transition(data: &Value) -> String {
249 if data["paused"] == Value::Bool(true) {
250 "scheduler paused\n".into()
251 } else {
252 "scheduler resumed\n".into()
253 }
254}
255
256fn render_stop(data: &Value) -> String {
257 if data["running"] == Value::Bool(false) {
258 return "daemon is not running\n".into();
259 }
260 let mut text = format!("daemon stopping (pid {})\n", data["pid"]);
261 for run in string_items(&data["cancelled_runs"]) {
262 let _ = writeln!(text, " cancelled: {run}");
263 }
264 text
265}
266
267fn render_wait(data: &Value) -> String {
268 format!(
269 "run {} {}\n",
270 data["run"].as_str().unwrap_or("?"),
271 data["state"].as_str().unwrap_or("?"),
272 )
273}
274
275fn render_cancel(data: &Value) -> String {
276 let mut text = format!("run {} cancelling\n", data["run"].as_str().unwrap_or("?"));
277 if let Some(worktree) = data["worktree"].as_str() {
278 let _ = writeln!(text, "worktree preserved at {worktree}");
279 }
280 text
281}
282
283fn render_logs(data: &Value) -> String {
284 let entries = data["entries"]
285 .as_array()
286 .map(Vec::as_slice)
287 .unwrap_or_default();
288 if entries.is_empty() {
289 return format!(
290 "no output captured for run {}\n",
291 data["run"].as_str().unwrap_or("?")
292 );
293 }
294 let mut text = String::new();
295 for entry in entries {
296 let mut origin = entry["source"].as_str().unwrap_or("?").to_owned();
297 if let Some(stage) = entry["stage"].as_str() {
298 let _ = write!(origin, ":{stage}");
299 }
300 let line = entry["text"].as_str().unwrap_or("<binary output>");
303 let _ = writeln!(text, "[{origin}] {line}");
304 }
305 text
306}
307
308fn render_show(data: &Value) -> String {
309 if data["kind"] != "project" {
310 return fallback(data);
311 }
312 let project = &data["value"];
313 let mut text = format!(
314 "project {} ({})\n",
315 project["title"].as_str().unwrap_or("?"),
316 project["id"].as_str().unwrap_or("?"),
317 );
318 let tickets = project["tickets"]
319 .as_array()
320 .map(Vec::as_slice)
321 .unwrap_or_default();
322 if tickets.is_empty() {
323 text.push_str("no tickets\n");
324 return text;
325 }
326 for ticket in tickets {
327 let _ = writeln!(
328 text,
329 "\n{} {} ({})",
330 ticket["id"].as_str().unwrap_or("?"),
331 ticket["name"].as_str().unwrap_or("?"),
332 ticket["state"].as_str().unwrap_or("?"),
333 );
334 for note in ticket["notes"]
335 .as_array()
336 .map(Vec::as_slice)
337 .unwrap_or_default()
338 {
339 let _ = writeln!(
340 text,
341 " note {} [{}]: {}",
342 note["id"].as_str().unwrap_or("?"),
343 note["run"].as_str().unwrap_or("?"),
344 note["text"].as_str().unwrap_or("?"),
345 );
346 }
347 for commit in ticket["commits"]
348 .as_array()
349 .map(Vec::as_slice)
350 .unwrap_or_default()
351 {
352 let _ = writeln!(
353 text,
354 " commit {} [{}]: {}",
355 commit["hash"].as_str().unwrap_or("?"),
356 commit["run"].as_str().unwrap_or("?"),
357 commit["message"].as_str().unwrap_or("?"),
358 );
359 }
360 }
361 text
362}
363
364fn fallback(data: &Value) -> String {
365 let mut text = serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string());
366 text.push('\n');
367 text
368}
369
370fn string_items(value: &Value) -> impl Iterator<Item = &str> {
371 value
372 .as_array()
373 .map(Vec::as_slice)
374 .unwrap_or_default()
375 .iter()
376 .filter_map(Value::as_str)
377}
378
379#[cfg(test)]
380mod tests {
381 use serde_json::json;
382
383 use super::render;
384 use crate::protocol::{ErrorBody, ErrorCode, ResponseEnvelope};
385
386 #[test]
387 fn errors_render_the_code_and_message() {
388 let response = ResponseEnvelope::failure(
389 None,
390 ErrorBody {
391 code: ErrorCode::Conflict,
392 message: "run `R1` is `merged` and cannot be cancelled".into(),
393 details: json!({}),
394 },
395 );
396
397 let text = render(Some("cancel"), &response);
398 assert_eq!(
399 text,
400 "error (conflict): run `R1` is `merged` and cannot be cancelled\n"
401 );
402 }
403
404 #[test]
405 fn status_renders_counts_and_active_runs() {
406 let response = ResponseEnvelope::success(
407 None,
408 json!({
409 "daemon": {"pid": 42, "paused": false},
410 "gate": {
411 "active_agents": 1,
412 "max_agents": 2,
413 "running_hours": {"start": "22:00", "end": "06:00", "open": false}
414 },
415 "next_wake": "2026-07-15T22:00:00Z",
416 "runs": [{"id": "R1", "state": "running", "ticket": "T1", "project": "default"}],
417 "queued_activations": [],
418 "tickets": {
419 "ready": 1, "held": 2, "blocked": 0, "claimed": 1,
420 "merged": 3, "failed": 0, "needs_review": 0
421 }
422 }),
423 );
424
425 let text = render(Some("status"), &response);
426 assert!(text.contains("daemon: pid 42\n"), "{text}");
427 assert!(text.contains("agents: 1 active of 2 max"), "{text}");
428 assert!(
429 text.contains("running hours: 22:00-06:00 (closed)"),
430 "{text}"
431 );
432 assert!(text.contains("next wake: 2026-07-15T22:00:00Z"), "{text}");
433 assert!(
434 text.contains(
435 "tickets: 1 ready, 2 held, 0 blocked, 1 claimed, 3 merged, 0 failed, 0 needs_review"
436 ),
437 "{text}"
438 );
439 assert!(
440 text.contains("R1 running (ticket T1, project default)"),
441 "{text}"
442 );
443 assert!(text.contains("queued: none"), "{text}");
444 }
445
446 #[test]
447 fn list_renders_reasons_but_not_running_or_terminal_reasons() {
448 let response = ResponseEnvelope::success(
449 None,
450 json!({
451 "tickets": [
452 {"id": "TICKET-1", "project": "default", "state": "ready", "run": null,
453 "reason": "scheduler is paused; resume with `sloop resume`"},
454 {"id": "T2", "project": "default", "state": "claimed", "run": "R1",
455 "reason": "claimed by run R1"},
456 {"id": "T3", "project": "web", "state": "merged", "run": null,
457 "reason": null}
458 ]
459 }),
460 );
461
462 assert_eq!(
463 render(Some("list"), &response),
464 "TICKET-1 ready (default) — scheduler is paused; resume with `sloop resume`\n\
465 T2 claimed (default)\n\
466 T3 merged (web)\n"
467 );
468 }
469
470 #[test]
471 fn empty_list_says_there_are_no_tickets() {
472 let response = ResponseEnvelope::success(None, json!({"tickets": []}));
473 assert_eq!(render(Some("list"), &response), "no tickets\n");
474 }
475
476 #[test]
477 fn hold_and_ready_render_the_transition() {
478 let response = ResponseEnvelope::success(
479 None,
480 json!({
481 "ticket": "T1",
482 "previous_state": "held",
483 "state": "ready",
484 "overridden": true,
485 }),
486 );
487
488 for verb in ["hold", "ready"] {
489 assert_eq!(render(Some(verb), &response), "ticket T1: held -> ready\n");
490 }
491 }
492
493 #[test]
494 fn retry_renders_the_transition() {
495 let response = ResponseEnvelope::success(
496 None,
497 json!({
498 "ticket": "T1",
499 "previous_state": "failed",
500 "state": "ready",
501 }),
502 );
503
504 assert_eq!(
505 render(Some("retry"), &response),
506 "ticket T1: failed -> ready\n"
507 );
508 }
509
510 #[test]
511 fn unknown_shapes_fall_back_to_pretty_json() {
512 let response = ResponseEnvelope::success(None, json!({"surprise": true}));
513 let text = render(Some("mystery"), &response);
514 assert!(text.contains("\"surprise\": true"), "{text}");
515 }
516
517 #[test]
518 fn project_show_groups_activity_by_ticket() {
519 let response = ResponseEnvelope::success(
520 None,
521 json!({
522 "ref": "backend",
523 "kind": "project",
524 "value": {
525 "id": "backend",
526 "title": "Backend",
527 "tickets": [{
528 "id": "T1",
529 "name": "Persist cooldowns",
530 "state": "merged",
531 "notes": [{"id": "N1", "run": "R1", "text": "halfway"}],
532 "commits": [{"hash": "abc1234", "run": "R1", "message": "persist cooldowns"}]
533 }]
534 }
535 }),
536 );
537
538 assert_eq!(
539 render(Some("show"), &response),
540 concat!(
541 "project Backend (backend)\n",
542 "\n",
543 "T1 Persist cooldowns (merged)\n",
544 " note N1 [R1]: halfway\n",
545 " commit abc1234 [R1]: persist cooldowns\n",
546 )
547 );
548 }
549
550 #[test]
551 fn ticket_show_keeps_the_existing_fallback_rendering() {
552 let data = json!({"ref": "T1", "kind": "ticket", "value": {"id": "T1"}});
553 let response = ResponseEnvelope::success(None, data.clone());
554 assert_eq!(render(Some("show"), &response), super::fallback(&data));
555 }
556
557 #[test]
558 fn pause_and_resume_render_the_scheduler_state() {
559 for (verb, paused, expected) in [
560 ("pause", true, "scheduler paused\n"),
561 ("resume", false, "scheduler resumed\n"),
562 ] {
563 let response = ResponseEnvelope::success(None, json!({"paused": paused}));
564 assert_eq!(render(Some(verb), &response), expected);
565 }
566 }
567
568 #[test]
569 fn genuinely_unimplemented_stubs_say_so_in_plain_words() {
570 let response =
571 ResponseEnvelope::success(None, json!({"verb": "reindex", "implemented": false}));
572 let text = render(Some("reindex"), &response);
573 assert_eq!(text, "reindex is not implemented by the daemon yet\n");
574 }
575
576 #[test]
577 fn log_entries_render_origin_and_text() {
578 let response = ResponseEnvelope::success(
579 None,
580 json!({
581 "run": "R1",
582 "entries": [
583 {"source": "agent", "stage": null, "text": "hello"},
584 {"source": "aftercare", "stage": "test", "bytes_b64": "AAE="}
585 ],
586 "next_cursor": 2,
587 "complete": true
588 }),
589 );
590
591 let text = render(Some("logs"), &response);
592 assert!(text.contains("[agent] hello"), "{text}");
593 assert!(text.contains("[aftercare:test] <binary output>"), "{text}");
594 }
595}