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