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("restart") => render_restart(data),
27 Some("init") => render_init(data),
28 Some("post") => render_post(data),
29 Some("run") => render_run(data),
30 Some("retry" | "hold" | "ready") => render_ticket_transition(data),
31 Some("list") => render_list(data),
32 Some("status") => render_status(data),
33 Some("pause" | "resume") => render_scheduler_transition(data),
34 Some("stop") => render_stop(data),
35 Some("wait") => render_wait(data),
36 Some("cancel") => render_cancel(data),
37 Some("logs") => render_logs(data),
38 Some("reindex") => render_reindex(data),
39 Some("show") => render_show(data),
40 _ => fallback(data),
41 }
42}
43
44pub fn render_error(error: &ErrorBody) -> String {
45 let code = serde_json::to_value(error.code)
46 .ok()
47 .and_then(|value| value.as_str().map(str::to_owned))
48 .unwrap_or_else(|| "error".into());
49 let mut text = format!("error ({code}): {}\n", error.message);
50 if error.details.as_object().is_some_and(|map| !map.is_empty()) {
51 let _ = writeln!(text, " details: {}", error.details);
52 }
53 text
54}
55
56fn render_daemon(data: &Value) -> String {
57 let pid = &data["pid"];
58 let state = if data["started"] == Value::Bool(true) {
59 "started"
60 } else {
61 "running"
62 };
63 let mut text = format!("daemon {state} (pid {pid})\n");
64 if let Some(socket) = data["socket"].as_str() {
65 let _ = writeln!(text, "socket: {socket}");
66 }
67 if let Some(log) = data["log"].as_str() {
68 let _ = writeln!(text, "log: {log}");
69 }
70 text
71}
72
73fn render_restart(data: &Value) -> String {
74 let active = data["active_runs"].as_u64().unwrap_or(0);
75 let noun = if active == 1 { "run" } else { "runs" };
76 format!("daemon draining for restart ({active} {noun} active)\n")
77}
78
79fn render_init(data: &Value) -> String {
80 let mut text = format!(
81 "initialized sloop in {}\n",
82 data["repository_root"].as_str().unwrap_or("?")
83 );
84 for (label, key) in [("created", "created"), ("existing", "existing")] {
85 for path in string_items(&data[key]) {
86 let _ = writeln!(text, " {label}: {path}");
87 }
88 }
89 text
90}
91
92fn render_post(data: &Value) -> String {
93 let ticket = &data["ticket"];
94 let mut text = format!(
95 "ticket {} registered from {} (project {}, {})\n",
96 ticket["id"].as_str().unwrap_or("?"),
97 ticket["file"].as_str().unwrap_or("?"),
98 ticket["project"].as_str().unwrap_or("?"),
99 ticket["state"].as_str().unwrap_or("?"),
100 );
101 text.push_str(&render_activation(&data["activation"]));
102 text
103}
104
105fn render_run(data: &Value) -> String {
106 render_activation(&data["activation"])
107}
108
109fn render_activation(activation: &Value) -> String {
110 let Some(fields) = activation.as_object() else {
111 return String::new();
112 };
113 let mut text = format!(
114 "activation {} {} ({}",
115 fields.get("id").and_then(Value::as_str).unwrap_or("?"),
116 fields
117 .get("state")
118 .and_then(Value::as_str)
119 .unwrap_or("queued"),
120 fields.get("kind").and_then(Value::as_str).unwrap_or("?"),
121 );
122 for key in ["ticket", "project", "time"] {
123 if let Some(value) = fields.get(key).and_then(Value::as_str) {
124 let _ = write!(text, ", {key} {value}");
125 }
126 }
127 text.push_str(")\n");
128 text
129}
130
131fn render_status(data: &Value) -> String {
132 let daemon = &data["daemon"];
133 let state = if daemon["draining"] == Value::Bool(true) {
134 let active = data["gate"]["active_agents"].as_u64().unwrap_or(0);
135 let noun = if active == 1 { "run" } else { "runs" };
136 format!(", draining for restart ({active} {noun} active)")
137 } else if daemon["paused"] == Value::Bool(true) {
138 ", paused".into()
139 } else {
140 String::new()
141 };
142 let mut text = format!("daemon: pid {}{state}\n", daemon["pid"]);
143 let _ = writeln!(
144 text,
145 "agents: {} active of {} max",
146 data["gate"]["active_agents"], data["gate"]["max_agents"]
147 );
148 if data["gate"]["storage"]["writable"] == Value::Bool(false) {
149 text.push_str("storage: database full (dispatch blocked until space is available)\n");
150 }
151 if let Some(hours) = data["gate"]["running_hours"].as_object() {
152 let state = if hours.get("open") == Some(&Value::Bool(true)) {
153 "open"
154 } else {
155 "closed"
156 };
157 let _ = writeln!(
158 text,
159 "running hours: {}-{} ({state})",
160 hours.get("start").and_then(Value::as_str).unwrap_or("?"),
161 hours.get("end").and_then(Value::as_str).unwrap_or("?"),
162 );
163 }
164 if let Some(next_wake) = data["next_wake"].as_str() {
165 let _ = writeln!(text, "next wake: {next_wake}");
166 }
167
168 let tickets = &data["tickets"];
169 let counts: Vec<String> = [
170 "ready",
171 "held",
172 "blocked",
173 "claimed",
174 "merged",
175 "failed",
176 "needs_review",
177 ]
178 .iter()
179 .map(|state| format!("{} {state}", tickets[*state]))
180 .collect();
181 let _ = writeln!(text, "tickets: {}", counts.join(", "));
182
183 let runs = data["runs"]
187 .as_array()
188 .map(Vec::as_slice)
189 .unwrap_or_default();
190 if runs.is_empty() {
191 text.push_str("runs: none\n");
192 } else {
193 text.push_str("runs:\n");
194 for run in runs {
195 let _ = writeln!(
196 text,
197 " {} {} — {} (project {})",
198 run["alias"].as_str().unwrap_or("?"),
199 run["state"].as_str().unwrap_or("?"),
200 run["ticket_name"].as_str().unwrap_or("?"),
201 run["project"].as_str().unwrap_or("-"),
202 );
203 }
204 }
205
206 let queued = data["queued_activations"]
207 .as_array()
208 .map(Vec::as_slice)
209 .unwrap_or_default();
210 if queued.is_empty() {
211 text.push_str("queued: none\n");
212 } else {
213 text.push_str("queued:\n");
214 for item in queued {
215 let _ = writeln!(
216 text,
217 " {} {} (ticket {}, project {})",
218 item["id"].as_str().unwrap_or("?"),
219 item["state"].as_str().unwrap_or("?"),
220 item["ticket"].as_str().unwrap_or("-"),
221 item["project"].as_str().unwrap_or("-"),
222 );
223 }
224 }
225 text
226}
227
228fn render_list(data: &Value) -> String {
229 let tickets = data["tickets"]
230 .as_array()
231 .map(Vec::as_slice)
232 .unwrap_or_default();
233 if tickets.is_empty() {
234 return "no tickets\n".into();
235 }
236
237 let id_width = tickets
238 .iter()
239 .filter_map(|ticket| ticket["id"].as_str())
240 .map(str::len)
241 .max()
242 .unwrap_or(1);
243 let state_width = tickets
244 .iter()
245 .filter_map(|ticket| ticket["state"].as_str())
246 .map(str::len)
247 .max()
248 .unwrap_or(1);
249 let mut text = String::new();
250 for ticket in tickets {
251 let id = ticket["id"].as_str().unwrap_or("?");
252 let state = ticket["state"].as_str().unwrap_or("?");
253 let project = ticket["project"].as_str().unwrap_or("?");
254 let name = ticket["name"].as_str().unwrap_or("?");
255 let _ = write!(
256 text,
257 "{id:id_width$} {state:state_width$} ({project}) {name}"
258 );
259 let terminal = matches!(state, "merged" | "needs_review");
260 if ticket["run"].is_null()
261 && !terminal
262 && let Some(reason) = ticket["reason"].as_str()
263 {
264 let _ = write!(text, " — {reason}");
265 }
266 text.push('\n');
267 }
268 text
269}
270
271fn render_ticket_transition(data: &Value) -> String {
272 format!(
273 "ticket {}: {} -> {}\n",
274 data["ticket"].as_str().unwrap_or("?"),
275 data["previous_state"].as_str().unwrap_or("?"),
276 data["state"].as_str().unwrap_or("?"),
277 )
278}
279
280fn render_scheduler_transition(data: &Value) -> String {
281 if data["paused"] == Value::Bool(true) {
282 "scheduler paused\n".into()
283 } else {
284 "scheduler resumed\n".into()
285 }
286}
287
288fn render_stop(data: &Value) -> String {
289 if data["running"] == Value::Bool(false) {
290 return "daemon is not running\n".into();
291 }
292 let mut text = format!("daemon stopping (pid {})\n", data["pid"]);
293 for run in string_items(&data["cancelled_runs"]) {
294 let _ = writeln!(text, " cancelled: {run}");
295 }
296 text
297}
298
299fn render_wait(data: &Value) -> String {
300 let mut rendered = String::new();
301 if let Some(note) = data["note"].as_str() {
302 let _ = writeln!(rendered, "{note}");
303 }
304 let _ = writeln!(
305 rendered,
306 "run {} {}",
307 data["alias"].as_str().unwrap_or("?"),
308 data["state"].as_str().unwrap_or("?"),
309 );
310 if let Some(reason) = data["reason"].as_str() {
311 let _ = writeln!(rendered, "reason: {reason}");
312 }
313 rendered
314}
315
316fn render_cancel(data: &Value) -> String {
317 let mut text = format!("run {} cancelling\n", data["alias"].as_str().unwrap_or("?"));
318 if let Some(worktree) = data["worktree"].as_str() {
319 let _ = writeln!(text, "worktree preserved at {worktree}");
320 }
321 text
322}
323
324fn render_logs(data: &Value) -> String {
325 let entries = data["entries"]
326 .as_array()
327 .map(Vec::as_slice)
328 .unwrap_or_default();
329 let mut text = String::new();
330 if let Some(note) = data["note"].as_str() {
331 let _ = writeln!(text, "{note}");
332 }
333 if entries.is_empty() {
334 let _ = writeln!(
335 text,
336 "no output captured for run {}",
337 data["alias"].as_str().unwrap_or("?")
338 );
339 return text;
340 }
341 for entry in entries {
342 let timestamp = entry["timestamp"].as_str().unwrap_or("?");
343 let mut origin = entry["source"].as_str().unwrap_or("?").to_owned();
344 if let Some(stage) = entry["stage"].as_str() {
345 let _ = write!(origin, ":{stage}");
346 }
347 let line = entry["text"].as_str().unwrap_or("<binary output>");
350 let _ = writeln!(text, "[{timestamp}] [{origin}] {line}");
351 }
352 text
353}
354
355fn render_reindex(data: &Value) -> String {
356 format!(
357 "reindexed {} projects and {} tickets; {} ticket states changed; {} rows dropped\n",
358 data["projects_indexed"],
359 data["tickets_indexed"],
360 data["tickets_state_changed"],
361 data["rows_dropped"]
362 )
363}
364
365fn render_show(data: &Value) -> String {
366 match data["kind"].as_str() {
367 Some("ticket") => render_ticket_show(data),
368 Some("run") => render_run_show(data),
369 Some("project") => render_project_show(data),
370 _ => fallback(data),
371 }
372}
373
374fn render_ticket_show(data: &Value) -> String {
378 let value = &data["value"];
379 let mut text = format!(
380 "{} {} ({})\n",
381 value["id"].as_str().unwrap_or("?"),
382 value["name"].as_str().unwrap_or("?"),
383 value["state"].as_str().unwrap_or("?"),
384 );
385 if let Some(project) = value["project"].as_str() {
386 let _ = writeln!(text, "project: {project}");
387 }
388 if let Some(worktree) = value["worktree"].as_str() {
389 let _ = writeln!(text, "worktree: {worktree}");
390 }
391 let blocked_by = string_items(&value["blocked_by"]).collect::<Vec<_>>();
392 if !blocked_by.is_empty() {
393 let _ = writeln!(text, "blocked_by: {}", blocked_by.join(", "));
394 }
395 for (label, key) in [
396 ("target", "target"),
397 ("model", "model"),
398 ("effort", "effort"),
399 ] {
400 if let Some(field) = value[key].as_str() {
401 let _ = writeln!(text, "{label}: {field}");
402 }
403 }
404 if let Some(reason) = value["reason"].as_str() {
405 let _ = writeln!(text, "reason: {reason}");
406 }
407 if let Some(body) = value["body"]
408 .as_str()
409 .map(str::trim)
410 .filter(|body| !body.is_empty())
411 {
412 let _ = write!(text, "\n{body}\n");
413 }
414 text
415}
416
417fn render_run_show(data: &Value) -> String {
419 let value = &data["value"];
420 let mut text = String::new();
421 if let Some(note) = value["note"].as_str() {
422 let _ = writeln!(text, "{note}");
423 }
424 let _ = writeln!(
425 text,
426 "{} ({})",
427 value["alias"].as_str().unwrap_or("?"),
428 value["state"].as_str().unwrap_or("?"),
429 );
430 let ticket = value["ticket"].as_str().unwrap_or("?");
431 match value["ticket_name"].as_str() {
432 Some(name) => {
433 let _ = writeln!(text, "ticket: {ticket} {name}");
434 }
435 None => {
436 let _ = writeln!(text, "ticket: {ticket}");
437 }
438 }
439 if let Some(branch) = value["branch"].as_str() {
440 let _ = writeln!(text, "branch: {branch}");
441 }
442 if let Some(worktree) = value["worktree"].as_str() {
443 let _ = writeln!(text, "worktree: {worktree}");
444 }
445 if let Some(exit_code) = value["exit_code"].as_i64() {
446 let _ = writeln!(text, "exit: {exit_code}");
447 }
448 if let Some(reason) = value["reason"].as_str() {
449 let _ = writeln!(text, "reason: {reason}");
450 }
451 text
452}
453
454fn render_project_show(data: &Value) -> String {
455 let project = &data["value"];
456 let mut text = format!(
457 "project {} ({})\n",
458 project["title"].as_str().unwrap_or("?"),
459 project["id"].as_str().unwrap_or("?"),
460 );
461 let tickets = project["tickets"]
462 .as_array()
463 .map(Vec::as_slice)
464 .unwrap_or_default();
465 if tickets.is_empty() {
466 text.push_str("no tickets\n");
467 return text;
468 }
469 for ticket in tickets {
470 let _ = writeln!(
471 text,
472 "\n{} {} ({})",
473 ticket["id"].as_str().unwrap_or("?"),
474 ticket["name"].as_str().unwrap_or("?"),
475 ticket["state"].as_str().unwrap_or("?"),
476 );
477 for note in ticket["notes"]
478 .as_array()
479 .map(Vec::as_slice)
480 .unwrap_or_default()
481 {
482 let _ = writeln!(
483 text,
484 " note {} [{}]: {}",
485 note["id"].as_str().unwrap_or("?"),
486 note["run"].as_str().unwrap_or("?"),
487 note["text"].as_str().unwrap_or("?"),
488 );
489 }
490 for commit in ticket["commits"]
491 .as_array()
492 .map(Vec::as_slice)
493 .unwrap_or_default()
494 {
495 let _ = writeln!(
496 text,
497 " commit {} [{}]: {}",
498 commit["hash"].as_str().unwrap_or("?"),
499 commit["run"].as_str().unwrap_or("?"),
500 commit["message"].as_str().unwrap_or("?"),
501 );
502 }
503 }
504 text
505}
506
507fn fallback(data: &Value) -> String {
508 let mut text = serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string());
509 text.push('\n');
510 text
511}
512
513fn string_items(value: &Value) -> impl Iterator<Item = &str> {
514 value
515 .as_array()
516 .map(Vec::as_slice)
517 .unwrap_or_default()
518 .iter()
519 .filter_map(Value::as_str)
520}
521
522#[cfg(test)]
523mod tests {
524 use serde_json::json;
525
526 use super::render;
527 use crate::protocol::{ErrorBody, ErrorCode, ResponseEnvelope};
528
529 #[test]
530 fn errors_render_the_code_and_message() {
531 let response = ResponseEnvelope::failure(
532 None,
533 ErrorBody {
534 code: ErrorCode::Conflict,
535 message: "run `R1` is `merged` and cannot be cancelled".into(),
536 details: json!({}),
537 },
538 );
539
540 let text = render(Some("cancel"), &response);
541 assert_eq!(
542 text,
543 "error (conflict): run `R1` is `merged` and cannot be cancelled\n"
544 );
545 }
546
547 #[test]
548 fn status_renders_counts_and_active_runs() {
549 let response = ResponseEnvelope::success(
550 None,
551 json!({
552 "daemon": {"pid": 42, "paused": false},
553 "gate": {
554 "active_agents": 1,
555 "max_agents": 2,
556 "running_hours": {"start": "22:00", "end": "06:00", "open": false}
557 },
558 "next_wake": "2026-07-15T22:00:00Z",
559 "runs": [{
560 "id": "3f2a9c1b7d4e5061a2b3c4d5e6f70819", "alias": "T1-r1",
561 "state": "running", "ticket": "T1", "ticket_name": "Generalized stages",
562 "project": "default"
563 }],
564 "queued_activations": [],
565 "tickets": {
566 "ready": 1, "held": 2, "blocked": 0, "claimed": 1,
567 "merged": 3, "failed": 0, "needs_review": 0
568 }
569 }),
570 );
571
572 let text = render(Some("status"), &response);
573 assert!(text.contains("daemon: pid 42\n"), "{text}");
574 assert!(text.contains("agents: 1 active of 2 max"), "{text}");
575 assert!(
576 text.contains("running hours: 22:00-06:00 (closed)"),
577 "{text}"
578 );
579 assert!(text.contains("next wake: 2026-07-15T22:00:00Z"), "{text}");
580 assert!(
581 text.contains(
582 "tickets: 1 ready, 2 held, 0 blocked, 1 claimed, 3 merged, 0 failed, 0 needs_review"
583 ),
584 "{text}"
585 );
586 assert!(
587 text.contains("T1-r1 running — Generalized stages (project default)"),
588 "{text}"
589 );
590 assert!(text.contains("queued: none"), "{text}");
591 }
592
593 #[test]
594 fn status_renders_a_full_database_as_a_dispatch_gate() {
595 let response = ResponseEnvelope::success(
596 None,
597 json!({
598 "daemon": {"pid": 42, "paused": false},
599 "gate": {
600 "active_agents": 0,
601 "max_agents": 1,
602 "storage": {"writable": false, "reason": "database_full"}
603 },
604 "runs": [],
605 "queued_activations": [],
606 "tickets": {}
607 }),
608 );
609
610 let text = render(Some("status"), &response);
611 assert!(
612 text.contains("storage: database full (dispatch blocked"),
613 "{text}"
614 );
615 }
616
617 #[test]
618 fn list_renders_reasons_but_not_running_or_terminal_reasons() {
619 let response = ResponseEnvelope::success(
620 None,
621 json!({
622 "tickets": [
623 {"id": "TICKET-1", "name": "Fix dispatch", "project": "default", "state": "ready", "run": null,
624 "reason": "scheduler is paused; resume with `sloop resume`"},
625 {"id": "T2", "name": "Add retries", "project": "default", "state": "claimed", "run": "R1",
626 "reason": "claimed by run R1"},
627 {"id": "T3", "name": "Polish the UI", "project": "web", "state": "merged", "run": null,
628 "reason": null}
629 ]
630 }),
631 );
632
633 assert_eq!(
634 render(Some("list"), &response),
635 "TICKET-1 ready (default) Fix dispatch — scheduler is paused; resume with `sloop resume`\n\
636 T2 claimed (default) Add retries\n\
637 T3 merged (web) Polish the UI\n"
638 );
639 }
640
641 #[test]
642 fn empty_list_says_there_are_no_tickets() {
643 let response = ResponseEnvelope::success(None, json!({"tickets": []}));
644 assert_eq!(render(Some("list"), &response), "no tickets\n");
645 }
646
647 #[test]
648 fn hold_and_ready_render_the_transition() {
649 let response = ResponseEnvelope::success(
650 None,
651 json!({
652 "ticket": "T1",
653 "previous_state": "held",
654 "state": "ready",
655 "overridden": true,
656 }),
657 );
658
659 for verb in ["hold", "ready"] {
660 assert_eq!(render(Some(verb), &response), "ticket T1: held -> ready\n");
661 }
662 }
663
664 #[test]
665 fn retry_renders_the_transition() {
666 let response = ResponseEnvelope::success(
667 None,
668 json!({
669 "ticket": "T1",
670 "previous_state": "failed",
671 "state": "ready",
672 }),
673 );
674
675 assert_eq!(
676 render(Some("retry"), &response),
677 "ticket T1: failed -> ready\n"
678 );
679 }
680
681 #[test]
682 fn unknown_shapes_fall_back_to_pretty_json() {
683 let response = ResponseEnvelope::success(None, json!({"surprise": true}));
684 let text = render(Some("mystery"), &response);
685 assert!(text.contains("\"surprise\": true"), "{text}");
686 }
687
688 #[test]
689 fn project_show_groups_activity_by_ticket() {
690 let response = ResponseEnvelope::success(
691 None,
692 json!({
693 "ref": "backend",
694 "kind": "project",
695 "value": {
696 "id": "backend",
697 "title": "Backend",
698 "tickets": [{
699 "id": "T1",
700 "name": "Persist cooldowns",
701 "state": "merged",
702 "notes": [{"id": "N1", "run": "R1", "text": "halfway"}],
703 "commits": [{"hash": "abc1234", "run": "R1", "message": "persist cooldowns"}]
704 }]
705 }
706 }),
707 );
708
709 assert_eq!(
710 render(Some("show"), &response),
711 concat!(
712 "project Backend (backend)\n",
713 "\n",
714 "T1 Persist cooldowns (merged)\n",
715 " note N1 [R1]: halfway\n",
716 " commit abc1234 [R1]: persist cooldowns\n",
717 )
718 );
719 }
720
721 #[test]
722 fn ticket_show_renders_a_summary_then_the_body() {
723 let response = ResponseEnvelope::success(
724 None,
725 json!({
726 "ref": "TICK-1",
727 "kind": "ticket",
728 "value": {
729 "id": "TICK-1",
730 "name": "cooldown",
731 "state": "ready",
732 "project": "default",
733 "worktree": "sloop/TICK-1",
734 "blocked_by": ["TICK-0"],
735 "target": "claude",
736 "model": "opus",
737 "effort": "high",
738 "body": "# Persist cooldowns\n\nSurvive restarts.",
739 }
740 }),
741 );
742
743 assert_eq!(
744 render(Some("show"), &response),
745 concat!(
746 "TICK-1 cooldown (ready)\n",
747 "project: default\n",
748 "worktree: sloop/TICK-1\n",
749 "blocked_by: TICK-0\n",
750 "target: claude\n",
751 "model: opus\n",
752 "effort: high\n",
753 "\n",
754 "# Persist cooldowns\n\nSurvive restarts.\n",
755 )
756 );
757 }
758
759 #[test]
760 fn ticket_show_without_a_body_renders_only_the_summary() {
761 let response = ResponseEnvelope::success(
762 None,
763 json!({
764 "ref": "T1",
765 "kind": "ticket",
766 "value": {"id": "T1", "name": "work", "state": "ready", "blocked_by": []}
767 }),
768 );
769
770 assert_eq!(render(Some("show"), &response), "T1 work (ready)\n");
771 }
772
773 #[test]
774 fn run_show_renders_the_run_evidence_summary() {
775 let response = ResponseEnvelope::success(
776 None,
777 json!({
778 "ref": "R14",
779 "kind": "run",
780 "value": {
781 "id": "R14",
782 "alias": "TICK-1-r2",
783 "ticket": "TICK-1",
784 "ticket_name": "cooldown",
785 "state": "merged",
786 "terminal": true,
787 "branch": "sloop/R14-TICK-1",
788 "worktree": "/repo/.worktrees/R14",
789 "exit_code": 0,
790 "reason": null,
791 "classification": null,
792 }
793 }),
794 );
795
796 assert_eq!(
797 render(Some("show"), &response),
798 concat!(
799 "TICK-1-r2 (merged)\n",
800 "ticket: TICK-1 cooldown\n",
801 "branch: sloop/R14-TICK-1\n",
802 "worktree: /repo/.worktrees/R14\n",
803 "exit: 0\n",
804 )
805 );
806 }
807
808 #[test]
809 fn pause_and_resume_render_the_scheduler_state() {
810 for (verb, paused, expected) in [
811 ("pause", true, "scheduler paused\n"),
812 ("resume", false, "scheduler resumed\n"),
813 ] {
814 let response = ResponseEnvelope::success(None, json!({"paused": paused}));
815 assert_eq!(render(Some(verb), &response), expected);
816 }
817 }
818
819 #[test]
820 fn restart_renders_the_active_drain_count() {
821 assert_eq!(
822 render(
823 Some("restart"),
824 &ResponseEnvelope::success(None, json!({"active_runs": 1}))
825 ),
826 "daemon draining for restart (1 run active)\n"
827 );
828 }
829
830 #[test]
831 fn reindex_summarizes_the_rebuilt_state() {
832 let response = ResponseEnvelope::success(
833 None,
834 json!({
835 "projects_indexed": 2,
836 "tickets_indexed": 5,
837 "tickets_state_changed": 1,
838 "state_changes": [
839 {"ticket": "T1", "previous_state": "ready", "state": "merged"}
840 ],
841 "rows_dropped": 7,
842 }),
843 );
844 let text = render(Some("reindex"), &response);
845 assert_eq!(
846 text,
847 "reindexed 2 projects and 5 tickets; 1 ticket states changed; 7 rows dropped\n"
848 );
849 }
850
851 #[test]
852 fn log_entries_render_timestamp_origin_and_text() {
853 let response = ResponseEnvelope::success(
854 None,
855 json!({
856 "run": "R1",
857 "entries": [
858 {"timestamp": "2026-07-17T12:34:56Z", "source": "agent", "stage": null, "text": "hello"},
859 {"timestamp": "2026-07-17T12:34:57Z", "source": "aftercare", "stage": "test", "bytes_b64": "AAE="}
860 ],
861 "next_cursor": 2,
862 "complete": true
863 }),
864 );
865
866 let text = render(Some("logs"), &response);
867 assert_eq!(
868 text,
869 "[2026-07-17T12:34:56Z] [agent] hello\n\
870 [2026-07-17T12:34:57Z] [aftercare:test] <binary output>\n"
871 );
872 }
873}