Skip to main content

start_command/
status_formatter.rs

1//! Status formatter module for execution records
2//!
3//! Provides formatting functions for execution status output in various formats:
4//! - Links Notation (links-notation): Structured link doublet format with nested options
5//! - JSON: Standard JSON output
6//! - Text: Human-readable text format
7
8use crate::docker_cleanup::docker_command;
9use crate::execution_control::collect_process_ids;
10use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
11use crate::output_blocks::{escape_for_links_notation, format_value_for_links_notation};
12use serde_json::Value;
13use std::fs;
14use std::io::{Read, Seek, SeekFrom};
15use std::process::Command;
16
17/// Live state of a detached docker container by name.
18#[derive(Clone, Copy)]
19struct DockerState {
20    running: bool,
21    exit_code: Option<i32>,
22    oom_killed: Option<bool>,
23}
24
25/// Inspect the live state of a detached docker container by name.
26///
27/// Distinguishes "running", "stopped (with a real exit code)", and "cannot be
28/// inspected at all". The last case matters on slow Docker-in-Docker hosts
29/// (issue #136): right after `docker run -d` returns, `docker inspect <name>`
30/// can transiently fail because the container is not visible yet. A failed
31/// inspect must NOT be read as "stopped"; it means "unknown", so callers can
32/// keep the session running instead of fabricating a terminal `-1` result.
33///
34/// Returns None when the container cannot be inspected (not found yet, removed,
35/// or docker error).
36fn inspect_docker_state(session_name: &str) -> Option<DockerState> {
37    let output = Command::new(docker_command())
38        .args([
39            "inspect",
40            "-f",
41            "{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}",
42            session_name,
43        ])
44        .output()
45        .ok()?;
46    if !output.status.success() {
47        return None;
48    }
49    let stdout = String::from_utf8_lossy(&output.stdout);
50    let trimmed = stdout.trim();
51    if trimmed.is_empty() {
52        return None;
53    }
54    let mut parts = trimmed.split_whitespace();
55    let running = parts.next() == Some("true");
56    let exit_code = parts.next().and_then(|value| value.parse::<i32>().ok());
57    let oom_killed = parts.next().and_then(|value| match value {
58        "true" => Some(true),
59        "false" => Some(false),
60        _ => None,
61    });
62    Some(DockerState {
63        running,
64        exit_code,
65        oom_killed,
66    })
67}
68
69fn is_detached_docker_record(record: &ExecutionRecord) -> bool {
70    record.options.get("isolated").and_then(|v| v.as_str()) == Some("docker")
71        && record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached")
72        && record
73            .options
74            .get("sessionName")
75            .and_then(|v| v.as_str())
76            .is_some()
77}
78
79fn read_docker_state(record: &ExecutionRecord) -> Option<DockerState> {
80    if record.options.get("isolated")?.as_str()? != "docker" {
81        return None;
82    }
83    let session_name = record.options.get("sessionName")?.as_str()?;
84    inspect_docker_state(session_name)
85}
86
87/// Best-effort terminal exit code reported by the isolation backend itself
88/// (currently docker via `docker inspect .State.ExitCode`). Returns None when
89/// the backend cannot provide a real code, so callers never surface the `-1`
90/// sentinel for a session whose real exit code is simply not available yet.
91/// A running container has no terminal exit code (docker reports `0` for it),
92/// so only a stopped container contributes one.
93fn backend_exit_code(docker_state: Option<DockerState>) -> Option<i32> {
94    let state = docker_state?;
95    if state.running {
96        None
97    } else {
98        state.exit_code
99    }
100}
101
102/// Reconcile the OOM observation from the stored record and from `docker inspect`.
103///
104/// `State.OOMKilled` is a container-cgroup flag: the kernel sets it when ANY
105/// process in the cgroup is OOM-killed and it is never cleared for the life of
106/// the container (moby/moby#47618). It is therefore an *observation*, never a
107/// verdict about the session (issue #151) — a container that lost one child
108/// process keeps running and can still exit `0`. Once observed, the flag stays
109/// `true` for the record.
110fn resolve_oom_observation(
111    record: &ExecutionRecord,
112    docker_state: Option<DockerState>,
113) -> Option<bool> {
114    let inspected = docker_state.and_then(|state| state.oom_killed);
115    if record.oom_killed == Some(true) || inspected == Some(true) {
116        return Some(true);
117    }
118    if record.oom_killed == Some(false) || inspected == Some(false) {
119        return Some(false);
120    }
121    None
122}
123
124/// Check if a detached isolation session is still running
125/// Returns Some(true) if running, Some(false) if not, None if unable to determine
126pub fn is_detached_session_alive(record: &ExecutionRecord) -> Option<bool> {
127    let session_name = record.options.get("sessionName")?.as_str()?;
128    let isolation_mode = record.options.get("isolationMode")?.as_str()?;
129    let isolated = record.options.get("isolated")?.as_str()?;
130
131    if isolation_mode != "detached" {
132        return None;
133    }
134
135    match isolated {
136        "screen" => {
137            let output = Command::new("screen").args(["-ls"]).output().ok()?;
138            let stdout = String::from_utf8_lossy(&output.stdout);
139            Some(stdout.contains(session_name))
140        }
141        "tmux" => {
142            let status = Command::new("tmux")
143                .args(["has-session", "-t", session_name])
144                .output()
145                .ok()?;
146            Some(status.status.success())
147        }
148        "docker" => {
149            // A failed inspect means the container is not visible yet (still
150            // being created on a slow DinD host) or already removed — not
151            // "stopped". Return None (unknown) so the session is not falsely
152            // marked finished (issue #136).
153            inspect_docker_state(session_name).map(|state| state.running)
154        }
155        "ssh" => {
156            // For SSH, check if the local wrapper PID is still running
157            #[cfg(unix)]
158            {
159                if let Some(pid) = record.pid {
160                    let result = unsafe { libc::kill(pid as i32, 0) };
161                    Some(result == 0)
162                } else {
163                    None
164                }
165            }
166            #[cfg(not(unix))]
167            {
168                let _ = record.pid;
169                None
170            }
171        }
172        _ => None,
173    }
174}
175
176/// Number of trailing bytes scanned for the terminal log footer. The footer is
177/// always appended at the very end of the log, so there is no reason to read
178/// (potentially megabytes of) command output on every `--status` call.
179const LOG_TAIL_BYTES: u64 = 16 * 1024;
180
181/// Read the last `bytes` bytes of a file as (lossy) UTF-8 text.
182///
183/// A partial first line is dropped: the slice can start in the middle of a
184/// line, and that fragment must not be treated as the beginning of a line by
185/// the anchored footer matcher.
186fn read_log_tail(log_path: &str, bytes: u64) -> Option<String> {
187    let mut file = fs::File::open(log_path).ok()?;
188    let size = file.metadata().ok()?.len();
189    let length = size.min(bytes);
190    file.seek(SeekFrom::Start(size - length)).ok()?;
191    let mut buffer = Vec::with_capacity(length as usize);
192    file.take(length).read_to_end(&mut buffer).ok()?;
193    let tail = String::from_utf8_lossy(&buffer).into_owned();
194    if size <= bytes {
195        return Some(tail);
196    }
197    Some(match tail.find('\n') {
198        Some(index) => tail[index + 1..].to_string(),
199        None => String::new(),
200    })
201}
202
203fn is_footer_separator_line(line: &str) -> bool {
204    line.len() >= 10 && line.chars().all(|c| c == '=')
205}
206
207/// Read the terminal exit code from the anchored footer at the end of a log.
208///
209/// The footer `start` itself writes (see `create_log_footer()` and
210/// `shell_log_footer_snippet()`) is a three-line block:
211///
212/// ```text
213/// ==================================================
214/// Finished: 2026-07-30 23:36:20.295
215/// Exit Code: 0
216/// ```
217///
218/// Matching the whole block at line starts means a bare `Exit Code: N`
219/// substring emitted by the wrapped command (inside a JSON payload, a quoted
220/// log excerpt, an `rg` dump, ...) can no longer be mistaken for the footer
221/// (issue #150). Returns None when the log has no footer yet — never a number
222/// parsed out of the command's own output.
223pub fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
224    let tail = read_log_tail(log_path, LOG_TAIL_BYTES)?;
225    let lines: Vec<&str> = tail
226        .lines()
227        .map(|line| line.trim_end_matches('\r'))
228        .collect();
229    for index in (2..lines.len()).rev() {
230        let value = match lines[index].strip_prefix("Exit Code:") {
231            Some(value) => value,
232            None => continue,
233        };
234        if !lines[index - 1].starts_with("Finished:") || !is_footer_separator_line(lines[index - 2])
235        {
236            continue;
237        }
238        if let Ok(code) = value.trim().parse::<i32>() {
239            return Some(code);
240        }
241    }
242    None
243}
244
245/// Enrich execution record with live session status for detached executions.
246/// If a record shows "executing" but the detached session has actually ended,
247/// returns an updated copy with status "executed". If it shows "executed" but
248/// the session is still running, returns a copy with status "executing".
249pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
250    let footer_exit = read_exit_code_from_log(&record.log_path);
251    let is_detached_docker = is_detached_docker_record(record);
252    let docker_state = if is_detached_docker {
253        read_docker_state(record)
254    } else {
255        None
256    };
257
258    // `oomKilled` is exposed alongside the status, but never decides it (#151).
259    let oom_killed = resolve_oom_observation(record, docker_state);
260    let clone_record = || {
261        let mut enriched = record.clone();
262        if oom_killed.is_some() {
263            enriched.oom_killed = oom_killed;
264        }
265        enriched
266    };
267
268    let alive = if is_detached_docker {
269        docker_state.map(|state| state.running)
270    } else {
271        is_detached_session_alive(record)
272    };
273
274    let alive = match alive {
275        Some(value) => value,
276        None => {
277            // Liveness is unknown: the backend could not be probed (e.g. a
278            // detached docker container that is not visible yet on a slow
279            // Docker-in-Docker host, or one that has already been removed).
280            // Honor a terminal `Exit Code:` footer if the command wrote one;
281            // otherwise leave the record untouched (still executing) rather than
282            // fabricating a `-1` terminal result that orchestrators misread as a
283            // finished/failed run (issue #136).
284            let is_detached =
285                record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached");
286            if is_detached && record.status == ExecutionStatus::Executing {
287                if footer_exit.is_some() {
288                    let mut enriched = clone_record();
289                    enriched.status = ExecutionStatus::Executed;
290                    enriched.exit_code = footer_exit;
291                    if enriched.end_time.is_none() {
292                        enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
293                    }
294                    return enriched;
295                }
296                if oom_killed == Some(true) {
297                    // The container is gone and wrote no footer, so the OOM
298                    // observation is the only evidence left: report the session
299                    // as finished with the conventional SIGKILL code (issue
300                    // #148). While the container is still inspectable this
301                    // branch is never taken — a live container keeps the session
302                    // `executing` no matter what the cgroup flag says (#151).
303                    let mut enriched = clone_record();
304                    enriched.status = ExecutionStatus::Executed;
305                    enriched.exit_code = Some(137);
306                    if enriched.end_time.is_none() {
307                        enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
308                    }
309                    return enriched;
310                }
311            }
312            return clone_record();
313        }
314    };
315
316    let mut enriched = clone_record();
317
318    if alive && enriched.status == ExecutionStatus::Executed {
319        // A live `screen -ls` (or `tmux`/`docker`) session does NOT mean the command
320        // is still running: a lingering shell can outlive a killed command (e.g. the
321        // OOM killer sends SIGKILL, exit 137, but the login shell stays up for a
322        // window after `start` already wrote the terminal footer). The footer/recorded
323        // exit code is authoritative. Only flip back to "executing" when there is NO
324        // recorded terminal exit code AND no `Exit Code:` footer in the log.
325        if enriched.exit_code.is_none() && footer_exit.is_none() {
326            // Session still running and no terminal record - correct it
327            enriched.status = ExecutionStatus::Executing;
328            enriched.exit_code = None;
329            enriched.end_time = None;
330        }
331        // Otherwise keep the recorded/footer exit code - the command has finished.
332    } else if !alive && enriched.status == ExecutionStatus::Executing {
333        // Session ended but record says executing - correct it. Resolve a real
334        // exit code: prefer the backend's own record (e.g. `docker inspect
335        // .State.ExitCode`, which is authoritative and cannot be spoofed by
336        // command output — issue #150), then the anchored log footer, then
337        // `137` when the only evidence left is the OOM observation, and only
338        // fall back to the `-1` sentinel as a last resort when no real code can
339        // be obtained (issues #136, #151).
340        enriched.status = ExecutionStatus::Executed;
341        if enriched.exit_code.is_none() {
342            enriched.exit_code = Some(
343                backend_exit_code(docker_state)
344                    .or(footer_exit)
345                    .or(if oom_killed == Some(true) {
346                        Some(137)
347                    } else {
348                        None
349                    })
350                    .unwrap_or(-1),
351            );
352        }
353        if enriched.end_time.is_none() {
354            enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
355        }
356    }
357
358    enriched
359}
360
361/// Compute a `currentTime` value for a record if its status is `executing`.
362/// Returns `None` for completed records. Wrapping this in a helper makes it
363/// easy to attach the same timestamp to all output formats and to test the
364/// behavior deterministically.
365pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
366    if record.status == ExecutionStatus::Executing {
367        Some(chrono::Utc::now().to_rfc3339())
368    } else {
369        None
370    }
371}
372
373/// Format execution record as Links Notation (indented style)
374/// Uses nested Links notation for object values (like options) instead of JSON
375///
376/// Output format:
377/// ```text
378/// <uuid>
379///   <key> <value>
380///   options
381///     <nested_key> <nested_value>
382///   ...
383/// ```
384pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
385    format_record_as_links_notation_with_current_time(record, None)
386}
387
388/// Same as [`format_record_as_links_notation`] but injects a `currentTime`
389/// field (right after `startTime`) when a value is supplied.
390pub fn format_record_as_links_notation_with_current_time(
391    record: &ExecutionRecord,
392    current_time: Option<&str>,
393) -> String {
394    format_record_as_links_notation_with_enrichments(record, current_time, None)
395}
396
397fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
398    let prefix = " ".repeat(indent);
399    if values.is_empty() {
400        lines.push(format!("{}()", prefix));
401        return;
402    }
403
404    lines.push(format!("{}(", prefix));
405    for value in values {
406        match value {
407            Value::Array(nested) => append_links_array(lines, nested, indent + 2),
408            Value::Object(map) => {
409                for (child_key, child_value) in map {
410                    if !child_value.is_null() {
411                        append_links_value(lines, child_key, child_value, indent + 2);
412                    }
413                }
414            }
415            _ => lines.push(format!(
416                "{}{}",
417                " ".repeat(indent + 2),
418                format_value_for_links_notation(value)
419            )),
420        }
421    }
422    lines.push(format!("{})", prefix));
423}
424
425fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
426    let prefix = " ".repeat(indent);
427    match value {
428        Value::Object(map) => {
429            if map.is_empty() {
430                return;
431            }
432            lines.push(format!("{}{}", prefix, key));
433            for (child_key, child_value) in map {
434                if !child_value.is_null() {
435                    append_links_value(lines, child_key, child_value, indent + 4);
436                }
437            }
438        }
439        Value::Array(values) => {
440            lines.push(format!("{}{}", prefix, key));
441            append_links_array(lines, values, indent + 2);
442        }
443        _ => lines.push(format!(
444            "{}{} {}",
445            prefix,
446            key,
447            format_value_for_links_notation(value)
448        )),
449    }
450}
451
452fn format_record_as_links_notation_with_enrichments(
453    record: &ExecutionRecord,
454    current_time: Option<&str>,
455    process_ids: Option<&Value>,
456) -> String {
457    let json = record.to_json();
458    let mut lines = vec![record.uuid.clone()];
459
460    if let Value::Object(map) = json {
461        for (key, value) in map {
462            if !value.is_null() {
463                if key == "options" {
464                    // Format options as nested Links notation
465                    if let Value::Object(opts) = &value {
466                        if !opts.is_empty() {
467                            lines.push("  options".to_string());
468                            for (opt_key, opt_value) in opts {
469                                if !opt_value.is_null() {
470                                    let formatted = format_value_for_links_notation(opt_value);
471                                    lines.push(format!("    {} {}", opt_key, formatted));
472                                }
473                            }
474                        }
475                    }
476                } else {
477                    let formatted_value = match &value {
478                        Value::String(s) => escape_for_links_notation(s),
479                        Value::Bool(b) => b.to_string(),
480                        Value::Number(n) => n.to_string(),
481                        Value::Null => "null".to_string(),
482                        Value::Object(_) | Value::Array(_) => {
483                            // For other complex types, use nested format
484                            format_value_for_links_notation(&value)
485                        }
486                    };
487                    lines.push(format!("  {} {}", key, formatted_value));
488                }
489            }
490
491            // Insert processIds right after pid so status output groups process
492            // identity with the wrapper PID already present in older output.
493            if key == "pid" {
494                if let Some(process_ids) = process_ids {
495                    append_links_value(&mut lines, "processIds", process_ids, 2);
496                }
497            }
498
499            // Insert currentTime right after startTime for readability
500            if key == "startTime" {
501                if let Some(ct) = current_time {
502                    lines.push(format!("  currentTime {}", escape_for_links_notation(ct)));
503                }
504            }
505        }
506    }
507
508    lines.join("\n")
509}
510
511/// Format execution record as human-readable text
512pub fn format_record_as_text(record: &ExecutionRecord) -> String {
513    format_record_as_text_with_current_time(record, None)
514}
515
516/// Same as [`format_record_as_text`] but adds a `Current Time:` line right
517/// after `Start Time:` when a value is supplied.
518pub fn format_record_as_text_with_current_time(
519    record: &ExecutionRecord,
520    current_time: Option<&str>,
521) -> String {
522    format_record_as_text_with_enrichments(record, current_time, None)
523}
524
525fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
526    let Value::Object(map) = process_ids else {
527        return;
528    };
529    if map.is_empty() {
530        return;
531    }
532
533    lines.push("Process IDs:".to_string());
534    for (key, value) in map {
535        let value_str = match value {
536            Value::String(s) => s.clone(),
537            Value::Bool(b) => b.to_string(),
538            Value::Number(n) => n.to_string(),
539            Value::Null => "null".to_string(),
540            other => serde_json::to_string(other).unwrap_or_default(),
541        };
542        lines.push(format!("  {}: {}", key, value_str));
543    }
544}
545
546fn format_record_as_text_with_enrichments(
547    record: &ExecutionRecord,
548    current_time: Option<&str>,
549    process_ids: Option<&Value>,
550) -> String {
551    let exit_code_str = record
552        .exit_code
553        .map(|c| c.to_string())
554        .unwrap_or_else(|| "N/A".to_string());
555    let pid_str = record
556        .pid
557        .map(|p| p.to_string())
558        .unwrap_or_else(|| "N/A".to_string());
559    let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
560
561    let mut lines = vec![
562        "Execution Status".to_string(),
563        "=".repeat(50),
564        format!("UUID:              {}", record.uuid),
565        format!("Status:            {}", record.status),
566        format!("Command:           {}", record.command),
567        format!("Exit Code:         {}", exit_code_str),
568    ];
569    if let Some(oom_killed) = record.oom_killed {
570        lines.push(format!("OOM Killed:        {}", oom_killed));
571    }
572    lines.push(format!("PID:               {}", pid_str));
573    if let Some(process_ids) = process_ids {
574        append_text_process_ids(&mut lines, process_ids);
575    }
576    lines.extend([
577        format!("Working Directory: {}", record.working_directory),
578        format!("Shell:             {}", record.shell),
579        format!("Platform:          {}", record.platform),
580        format!("Start Time:        {}", record.start_time),
581    ]);
582    if let Some(ct) = current_time {
583        lines.push(format!("Current Time:      {}", ct));
584    }
585    lines.push(format!("End Time:          {}", end_time_str));
586    lines.push(format!("Log Path:          {}", record.log_path));
587
588    // Format options as nested list instead of JSON
589    if !record.options.is_empty() {
590        lines.push("Options:".to_string());
591        for (key, value) in &record.options {
592            let value_str = match value {
593                Value::String(s) => s.clone(),
594                Value::Bool(b) => b.to_string(),
595                Value::Number(n) => n.to_string(),
596                Value::Null => "null".to_string(),
597                other => serde_json::to_string(other).unwrap_or_default(),
598            };
599            lines.push(format!("  {}: {}", key, value_str));
600        }
601    }
602
603    lines.join("\n")
604}
605
606fn record_json_with_enrichments(
607    record: &ExecutionRecord,
608    current_time: Option<&str>,
609    process_ids: Option<&Value>,
610) -> Value {
611    let mut json = record.to_json();
612    if let Value::Object(map) = &mut json {
613        if let Some(process_ids) = process_ids {
614            map.insert("processIds".to_string(), process_ids.clone());
615        }
616        if let Some(ct) = current_time {
617            map.insert("currentTime".to_string(), Value::String(ct.to_string()));
618        }
619    }
620    json
621}
622
623/// Format execution record based on format type
624pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
625    format_record_with_current_time(record, format, None)
626}
627
628/// Same as [`format_record`] but the output includes `currentTime` when a
629/// value is supplied. Use this from [`query_status`] so all three formats
630/// stay in sync.
631pub fn format_record_with_current_time(
632    record: &ExecutionRecord,
633    format: &str,
634    current_time: Option<&str>,
635) -> Result<String, String> {
636    format_record_with_enrichments(record, format, current_time, None)
637}
638
639fn format_record_with_enrichments(
640    record: &ExecutionRecord,
641    format: &str,
642    current_time: Option<&str>,
643    process_ids: Option<&Value>,
644) -> Result<String, String> {
645    match format {
646        "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
647            record,
648            current_time,
649            process_ids,
650        )),
651        "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
652            record,
653            current_time,
654            process_ids,
655        ))
656        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
657        "text" => Ok(format_record_as_text_with_enrichments(
658            record,
659            current_time,
660            process_ids,
661        )),
662        _ => Err(format!("Unknown output format: {}", format)),
663    }
664}
665
666fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
667    records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
668}
669
670fn indent_block(block: &str, spaces: usize) -> String {
671    let prefix = " ".repeat(spaces);
672    block
673        .lines()
674        .map(|line| format!("{}{}", prefix, line))
675        .collect::<Vec<_>>()
676        .join("\n")
677}
678
679/// Format execution records as a Links Notation list.
680pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
681    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
682    let process_ids = vec![None; records.len()];
683    format_record_list_as_links_notation_with_current_times(records, &current_times, &process_ids)
684}
685
686fn format_record_list_as_links_notation_with_current_times(
687    records: &[ExecutionRecord],
688    current_times: &[Option<String>],
689    process_ids: &[Option<Value>],
690) -> String {
691    let mut lines = vec![
692        "executions".to_string(),
693        format!("  count {}", records.len()),
694    ];
695
696    if records.is_empty() {
697        lines.push("  records ()".to_string());
698        return lines.join("\n");
699    }
700
701    lines.push("  records".to_string());
702    for ((record, current_time), process_ids) in records
703        .iter()
704        .zip(current_times.iter())
705        .zip(process_ids.iter())
706    {
707        let block = format_record_as_links_notation_with_enrichments(
708            record,
709            current_time.as_deref(),
710            process_ids.as_ref(),
711        );
712        lines.push(indent_block(&block, 4));
713    }
714
715    lines.join("\n")
716}
717
718/// Format execution records as human-readable text.
719pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
720    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
721    let process_ids = vec![None; records.len()];
722    format_record_list_as_text_with_current_times(records, &current_times, &process_ids)
723}
724
725fn format_record_list_as_text_with_current_times(
726    records: &[ExecutionRecord],
727    current_times: &[Option<String>],
728    process_ids: &[Option<Value>],
729) -> String {
730    let mut lines = vec![
731        "Executions".to_string(),
732        "=".repeat(50),
733        format!("Count: {}", records.len()),
734    ];
735
736    for ((record, current_time), process_ids) in records
737        .iter()
738        .zip(current_times.iter())
739        .zip(process_ids.iter())
740    {
741        lines.push(String::new());
742        lines.push(format_record_as_text_with_enrichments(
743            record,
744            current_time.as_deref(),
745            process_ids.as_ref(),
746        ));
747    }
748
749    lines.join("\n")
750}
751
752fn record_list_json_with_current_times(
753    records: &[ExecutionRecord],
754    current_times: &[Option<String>],
755    process_ids: &[Option<Value>],
756) -> Value {
757    let executions: Vec<Value> = records
758        .iter()
759        .zip(current_times.iter())
760        .zip(process_ids.iter())
761        .map(|((record, current_time), process_ids)| {
762            record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
763        })
764        .collect();
765
766    serde_json::json!({
767        "count": records.len(),
768        "executions": executions,
769    })
770}
771
772/// Format execution records based on format type.
773pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
774    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
775    let process_ids = vec![None; records.len()];
776    format_record_list_with_current_times(records, format, &current_times, &process_ids)
777}
778
779fn format_record_list_with_current_times(
780    records: &[ExecutionRecord],
781    format: &str,
782    current_times: &[Option<String>],
783    process_ids: &[Option<Value>],
784) -> Result<String, String> {
785    match format {
786        "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
787            records,
788            current_times,
789            process_ids,
790        )),
791        "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
792            records,
793            current_times,
794            process_ids,
795        ))
796        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
797        "text" => Ok(format_record_list_as_text_with_current_times(
798            records,
799            current_times,
800            process_ids,
801        )),
802        _ => Err(format!("Unknown output format: {}", format)),
803    }
804}
805
806/// Query result from status lookup
807pub struct StatusQueryResult {
808    pub success: bool,
809    pub output: Option<String>,
810    pub error: Option<String>,
811}
812
813/// Handle execution list query and return the result
814pub fn list_executions(
815    store: Option<&ExecutionStore>,
816    output_format: Option<&str>,
817) -> StatusQueryResult {
818    let store = match store {
819        Some(s) => s,
820        None => {
821            return StatusQueryResult {
822                success: false,
823                output: None,
824                error: Some("Execution tracking is disabled.".to_string()),
825            }
826        }
827    };
828
829    let mut records: Vec<ExecutionRecord> =
830        store.get_all().iter().map(enrich_detached_status).collect();
831    sort_records_by_start_time_desc(&mut records);
832    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
833    let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
834    let format = output_format.unwrap_or("links-notation");
835
836    match format_record_list_with_current_times(&records, format, &current_times, &process_ids) {
837        Ok(output) => StatusQueryResult {
838            success: true,
839            output: Some(output),
840            error: None,
841        },
842        Err(e) => StatusQueryResult {
843            success: false,
844            output: None,
845            error: Some(e),
846        },
847    }
848}
849
850/// Handle status query and return the result
851pub fn query_status(
852    store: Option<&ExecutionStore>,
853    identifier: &str,
854    output_format: Option<&str>,
855) -> StatusQueryResult {
856    let store = match store {
857        Some(s) => s,
858        None => {
859            return StatusQueryResult {
860                success: false,
861                output: None,
862                error: Some("Execution tracking is disabled.".to_string()),
863            }
864        }
865    };
866
867    let record = match store.get(identifier) {
868        Some(r) => r,
869        None => {
870            return StatusQueryResult {
871                success: false,
872                output: None,
873                error: Some(format!(
874                    "No execution found with UUID or session name: {}",
875                    identifier
876                )),
877            }
878        }
879    };
880
881    // Enrich detached execution status with live session check
882    let enriched = enrich_detached_status(&record);
883    // Attach currentTime so callers can see how long an executing command has been running
884    let current_time = attach_current_time(&enriched);
885    let process_ids = collect_process_ids(&enriched);
886
887    let format = output_format.unwrap_or("links-notation");
888    match format_record_with_enrichments(
889        &enriched,
890        format,
891        current_time.as_deref(),
892        process_ids.as_ref(),
893    ) {
894        Ok(output) => StatusQueryResult {
895            success: true,
896            output: Some(output),
897            error: None,
898        },
899        Err(e) => StatusQueryResult {
900            success: false,
901            output: None,
902            error: Some(e),
903        },
904    }
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use crate::execution_store::ExecutionRecordOptions;
911    use serde_json::json;
912
913    fn executing_record() -> ExecutionRecord {
914        ExecutionRecord::with_options(ExecutionRecordOptions {
915            command: "sleep 60".to_string(),
916            uuid: Some("issue-126-rust".to_string()),
917            pid: Some(667105),
918            status: Some(ExecutionStatus::Executing),
919            log_path: Some("/tmp/issue-126.log".to_string()),
920            start_time: Some("2026-04-23T10:00:00Z".to_string()),
921            working_directory: Some("/home/user".to_string()),
922            shell: Some("/bin/bash".to_string()),
923            platform: Some("linux".to_string()),
924            ..Default::default()
925        })
926    }
927
928    #[test]
929    fn links_notation_indents_nested_process_id_arrays() {
930        let process_ids = json!({
931            "wrapperPid": 667105,
932            "screenPid": 667120,
933            "commandPids": [667121, 667122],
934        });
935        let output = format_record_with_enrichments(
936            &executing_record(),
937            "links-notation",
938            Some("2026-04-23T10:10:13.042Z"),
939            Some(&process_ids),
940        )
941        .expect("links-notation should format");
942
943        assert!(
944            output.contains(
945                "      commandPids\n        (\n          667121\n          667122\n        )"
946            ),
947            "processIds should be a nested indented block, output: {}",
948            output
949        );
950        assert!(
951            !output.contains("\n(\n"),
952            "opening parenthesis must not start at column 1: {}",
953            output
954        );
955    }
956}