Skip to main content

sloop/
render.rs

1//! Human rendering of response envelopes. JSON envelopes remain the internal
2//! and `--json` representation; this module is a one-way translation applied
3//! at the final write, so the socket API and agent-facing output are
4//! unaffected by presentation changes here.
5
6use std::fmt::Write;
7
8use serde_json::Value;
9
10use crate::protocol::{ErrorBody, ResponseEnvelope};
11
12/// Renders a response envelope as human-readable text. `verb` selects a
13/// verb-specific layout; unknown or absent verbs fall back to pretty JSON so
14/// no response is ever silently dropped.
15pub 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        // Bytes that failed UTF-8 decoding are stored as base64; a human
301        // view labels them rather than printing garbage.
302        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}