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::exit_reason::{resolve_exit_reason, resolve_memory_exhaustion};
12use crate::isolation::isolation_log::{read_log_tail, FATAL_MARKER_TAIL_BYTES, LOG_TAIL_BYTES};
13use crate::output_blocks::{escape_for_links_notation, format_value_for_links_notation};
14use serde_json::Value;
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
176fn is_footer_separator_line(line: &str) -> bool {
177    line.len() >= 10 && line.chars().all(|c| c == '=')
178}
179
180/// Read the terminal exit code from the anchored footer at the end of a log.
181///
182/// The footer `start` itself writes (see `create_log_footer()` and
183/// `shell_log_footer_snippet()`) is a three-line block:
184///
185/// ```text
186/// ==================================================
187/// Finished: 2026-07-30 23:36:20.295
188/// Exit Code: 0
189/// ```
190///
191/// Matching the whole block at line starts means a bare `Exit Code: N`
192/// substring emitted by the wrapped command (inside a JSON payload, a quoted
193/// log excerpt, an `rg` dump, ...) can no longer be mistaken for the footer
194/// (issue #150). Returns None when the log has no footer yet — never a number
195/// parsed out of the command's own output.
196pub fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
197    let tail = read_log_tail(log_path, LOG_TAIL_BYTES)?;
198    let lines: Vec<&str> = tail
199        .lines()
200        .map(|line| line.trim_end_matches('\r'))
201        .collect();
202    for index in (2..lines.len()).rev() {
203        let value = match lines[index].strip_prefix("Exit Code:") {
204            Some(value) => value,
205            None => continue,
206        };
207        if !lines[index - 1].starts_with("Finished:") || !is_footer_separator_line(lines[index - 2])
208        {
209            continue;
210        }
211        if let Ok(code) = value.trim().parse::<i32>() {
212            return Some(code);
213        }
214    }
215    None
216}
217
218/// Enrich execution record with live session status and an exit reason hint.
219///
220/// The hint explains an otherwise opaque exit code (`139` with `oomKilled
221/// false` is the motivating case from issue #162) and is derived from the same
222/// log tail the footer scan already reads. It is purely additive: `status`,
223/// `exitCode` and `oomKilled` are never changed by it. The same tail also
224/// yields the `memoryExhausted` observation for consumers that key off
225/// `oomKilled` (issue #165).
226pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
227    let mut enriched = resolve_detached_status(record);
228    if enriched.status != ExecutionStatus::Executed {
229        return enriched;
230    }
231    let tail = read_log_tail(&enriched.log_path, FATAL_MARKER_TAIL_BYTES);
232    if enriched.exit_reason.is_none() {
233        enriched.exit_reason =
234            resolve_exit_reason(enriched.exit_code, tail.as_deref(), enriched.oom_killed);
235    }
236    if enriched.memory_exhausted.is_none() {
237        if let Some(memory) =
238            resolve_memory_exhaustion(enriched.exit_code, tail.as_deref(), enriched.oom_killed)
239        {
240            enriched.memory_exhausted = Some(memory.memory_exhausted);
241            enriched.memory_exhausted_reason = Some(memory.memory_exhausted_reason);
242        }
243    }
244    enriched
245}
246
247/// Reconcile the stored status of a detached execution with its live session.
248/// If a record shows "executing" but the detached session has actually ended,
249/// returns an updated copy with status "executed". If it shows "executed" but
250/// the session is still running, returns a copy with status "executing".
251fn resolve_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
252    let footer_exit = read_exit_code_from_log(&record.log_path);
253    let is_detached_docker = is_detached_docker_record(record);
254    let docker_state = if is_detached_docker {
255        read_docker_state(record)
256    } else {
257        None
258    };
259
260    // `oomKilled` is exposed alongside the status, but never decides it (#151).
261    let oom_killed = resolve_oom_observation(record, docker_state);
262    let clone_record = || {
263        let mut enriched = record.clone();
264        if oom_killed.is_some() {
265            enriched.oom_killed = oom_killed;
266        }
267        enriched
268    };
269
270    let alive = if is_detached_docker {
271        docker_state.map(|state| state.running)
272    } else {
273        is_detached_session_alive(record)
274    };
275
276    let alive = match alive {
277        Some(value) => value,
278        None => {
279            // Liveness is unknown: the backend could not be probed (e.g. a
280            // detached docker container that is not visible yet on a slow
281            // Docker-in-Docker host, or one that has already been removed).
282            // Honor a terminal `Exit Code:` footer if the command wrote one;
283            // otherwise leave the record untouched (still executing) rather than
284            // fabricating a `-1` terminal result that orchestrators misread as a
285            // finished/failed run (issue #136).
286            let is_detached =
287                record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached");
288            if is_detached && record.status == ExecutionStatus::Executing {
289                if footer_exit.is_some() {
290                    let mut enriched = clone_record();
291                    enriched.status = ExecutionStatus::Executed;
292                    enriched.exit_code = footer_exit;
293                    if enriched.end_time.is_none() {
294                        enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
295                    }
296                    return enriched;
297                }
298                if oom_killed == Some(true) {
299                    // The container is gone and wrote no footer, so the OOM
300                    // observation is the only evidence left: report the session
301                    // as finished with the conventional SIGKILL code (issue
302                    // #148). While the container is still inspectable this
303                    // branch is never taken — a live container keeps the session
304                    // `executing` no matter what the cgroup flag says (#151).
305                    let mut enriched = clone_record();
306                    enriched.status = ExecutionStatus::Executed;
307                    enriched.exit_code = Some(137);
308                    if enriched.end_time.is_none() {
309                        enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
310                    }
311                    return enriched;
312                }
313            }
314            return clone_record();
315        }
316    };
317
318    let mut enriched = clone_record();
319
320    if alive && enriched.status == ExecutionStatus::Executed {
321        // A live `screen -ls` (or `tmux`/`docker`) session does NOT mean the command
322        // is still running: a lingering shell can outlive a killed command (e.g. the
323        // OOM killer sends SIGKILL, exit 137, but the login shell stays up for a
324        // window after `start` already wrote the terminal footer). The footer/recorded
325        // exit code is authoritative. Only flip back to "executing" when there is NO
326        // recorded terminal exit code AND no `Exit Code:` footer in the log.
327        if enriched.exit_code.is_none() && footer_exit.is_none() {
328            // Session still running and no terminal record - correct it
329            enriched.status = ExecutionStatus::Executing;
330            enriched.exit_code = None;
331            enriched.end_time = None;
332        }
333        // Otherwise keep the recorded/footer exit code - the command has finished.
334    } else if !alive && enriched.status == ExecutionStatus::Executing {
335        // Session ended but record says executing - correct it. Resolve a real
336        // exit code: prefer the backend's own record (e.g. `docker inspect
337        // .State.ExitCode`, which is authoritative and cannot be spoofed by
338        // command output — issue #150), then the anchored log footer, then
339        // `137` when the only evidence left is the OOM observation, and only
340        // fall back to the `-1` sentinel as a last resort when no real code can
341        // be obtained (issues #136, #151).
342        enriched.status = ExecutionStatus::Executed;
343        if enriched.exit_code.is_none() {
344            enriched.exit_code = Some(
345                backend_exit_code(docker_state)
346                    .or(footer_exit)
347                    .or(if oom_killed == Some(true) {
348                        Some(137)
349                    } else {
350                        None
351                    })
352                    .unwrap_or(-1),
353            );
354        }
355        if enriched.end_time.is_none() {
356            enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
357        }
358    }
359
360    enriched
361}
362
363/// Compute a `currentTime` value for a record if its status is `executing`.
364/// Returns `None` for completed records. Wrapping this in a helper makes it
365/// easy to attach the same timestamp to all output formats and to test the
366/// behavior deterministically.
367pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
368    if record.status == ExecutionStatus::Executing {
369        Some(chrono::Utc::now().to_rfc3339())
370    } else {
371        None
372    }
373}
374
375/// Format execution record as Links Notation (indented style)
376/// Uses nested Links notation for object values (like options) instead of JSON
377///
378/// Output format:
379/// ```text
380/// <uuid>
381///   <key> <value>
382///   options
383///     <nested_key> <nested_value>
384///   ...
385/// ```
386pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
387    format_record_as_links_notation_with_current_time(record, None)
388}
389
390/// Same as [`format_record_as_links_notation`] but injects a `currentTime`
391/// field (right after `startTime`) when a value is supplied.
392pub fn format_record_as_links_notation_with_current_time(
393    record: &ExecutionRecord,
394    current_time: Option<&str>,
395) -> String {
396    format_record_as_links_notation_with_enrichments(record, current_time, None)
397}
398
399fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
400    let prefix = " ".repeat(indent);
401    if values.is_empty() {
402        lines.push(format!("{}()", prefix));
403        return;
404    }
405
406    lines.push(format!("{}(", prefix));
407    for value in values {
408        match value {
409            Value::Array(nested) => append_links_array(lines, nested, indent + 2),
410            Value::Object(map) => {
411                for (child_key, child_value) in map {
412                    if !child_value.is_null() {
413                        append_links_value(lines, child_key, child_value, indent + 2);
414                    }
415                }
416            }
417            _ => lines.push(format!(
418                "{}{}",
419                " ".repeat(indent + 2),
420                format_value_for_links_notation(value)
421            )),
422        }
423    }
424    lines.push(format!("{})", prefix));
425}
426
427fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
428    let prefix = " ".repeat(indent);
429    match value {
430        Value::Object(map) => {
431            if map.is_empty() {
432                return;
433            }
434            lines.push(format!("{}{}", prefix, key));
435            for (child_key, child_value) in map {
436                if !child_value.is_null() {
437                    append_links_value(lines, child_key, child_value, indent + 4);
438                }
439            }
440        }
441        Value::Array(values) => {
442            lines.push(format!("{}{}", prefix, key));
443            append_links_array(lines, values, indent + 2);
444        }
445        _ => lines.push(format!(
446            "{}{} {}",
447            prefix,
448            key,
449            format_value_for_links_notation(value)
450        )),
451    }
452}
453
454fn format_record_as_links_notation_with_enrichments(
455    record: &ExecutionRecord,
456    current_time: Option<&str>,
457    process_ids: Option<&Value>,
458) -> String {
459    let json = record.to_json();
460    let mut lines = vec![record.uuid.clone()];
461
462    if let Value::Object(map) = json {
463        for (key, value) in map {
464            if !value.is_null() {
465                if key == "options" {
466                    // Format options as nested Links notation
467                    if let Value::Object(opts) = &value {
468                        if !opts.is_empty() {
469                            lines.push("  options".to_string());
470                            for (opt_key, opt_value) in opts {
471                                if !opt_value.is_null() {
472                                    let formatted = format_value_for_links_notation(opt_value);
473                                    lines.push(format!("    {} {}", opt_key, formatted));
474                                }
475                            }
476                        }
477                    }
478                } else {
479                    let formatted_value = match &value {
480                        Value::String(s) => escape_for_links_notation(s),
481                        Value::Bool(b) => b.to_string(),
482                        Value::Number(n) => n.to_string(),
483                        Value::Null => "null".to_string(),
484                        Value::Object(_) | Value::Array(_) => {
485                            // For other complex types, use nested format
486                            format_value_for_links_notation(&value)
487                        }
488                    };
489                    lines.push(format!("  {} {}", key, formatted_value));
490                }
491            }
492
493            // Insert processIds right after pid so status output groups process
494            // identity with the wrapper PID already present in older output.
495            if key == "pid" {
496                if let Some(process_ids) = process_ids {
497                    append_links_value(&mut lines, "processIds", process_ids, 2);
498                }
499            }
500
501            // Insert currentTime right after startTime for readability
502            if key == "startTime" {
503                if let Some(ct) = current_time {
504                    lines.push(format!("  currentTime {}", escape_for_links_notation(ct)));
505                }
506            }
507        }
508    }
509
510    lines.join("\n")
511}
512
513/// Format execution record as human-readable text
514pub fn format_record_as_text(record: &ExecutionRecord) -> String {
515    format_record_as_text_with_current_time(record, None)
516}
517
518/// Same as [`format_record_as_text`] but adds a `Current Time:` line right
519/// after `Start Time:` when a value is supplied.
520pub fn format_record_as_text_with_current_time(
521    record: &ExecutionRecord,
522    current_time: Option<&str>,
523) -> String {
524    format_record_as_text_with_enrichments(record, current_time, None)
525}
526
527fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
528    let Value::Object(map) = process_ids else {
529        return;
530    };
531    if map.is_empty() {
532        return;
533    }
534
535    lines.push("Process IDs:".to_string());
536    for (key, value) in map {
537        let value_str = match value {
538            Value::String(s) => s.clone(),
539            Value::Bool(b) => b.to_string(),
540            Value::Number(n) => n.to_string(),
541            Value::Null => "null".to_string(),
542            other => serde_json::to_string(other).unwrap_or_default(),
543        };
544        lines.push(format!("  {}: {}", key, value_str));
545    }
546}
547
548fn format_record_as_text_with_enrichments(
549    record: &ExecutionRecord,
550    current_time: Option<&str>,
551    process_ids: Option<&Value>,
552) -> String {
553    let exit_code_str = record
554        .exit_code
555        .map(|c| c.to_string())
556        .unwrap_or_else(|| "N/A".to_string());
557    let pid_str = record
558        .pid
559        .map(|p| p.to_string())
560        .unwrap_or_else(|| "N/A".to_string());
561    let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
562
563    let mut lines = vec![
564        "Execution Status".to_string(),
565        "=".repeat(50),
566        format!("UUID:              {}", record.uuid),
567        format!("Status:            {}", record.status),
568        format!("Command:           {}", record.command),
569        format!("Exit Code:         {}", exit_code_str),
570    ];
571    if let Some(oom_killed) = record.oom_killed {
572        lines.push(format!("OOM Killed:        {}", oom_killed));
573    }
574    if let Some(ref exit_reason) = record.exit_reason {
575        lines.push(format!("Exit Reason:       {}", exit_reason));
576    }
577    if let Some(memory_exhausted) = record.memory_exhausted {
578        lines.push(format!("Memory Exhausted:  {}", memory_exhausted));
579    }
580    if let Some(ref reason) = record.memory_exhausted_reason {
581        lines.push(format!("Memory Evidence:   {}", reason));
582    }
583    lines.push(format!("PID:               {}", pid_str));
584    if let Some(process_ids) = process_ids {
585        append_text_process_ids(&mut lines, process_ids);
586    }
587    lines.extend([
588        format!("Working Directory: {}", record.working_directory),
589        format!("Shell:             {}", record.shell),
590        format!("Platform:          {}", record.platform),
591        format!("Start Time:        {}", record.start_time),
592    ]);
593    if let Some(ct) = current_time {
594        lines.push(format!("Current Time:      {}", ct));
595    }
596    lines.push(format!("End Time:          {}", end_time_str));
597    lines.push(format!("Log Path:          {}", record.log_path));
598
599    // Format options as nested list instead of JSON
600    if !record.options.is_empty() {
601        lines.push("Options:".to_string());
602        for (key, value) in &record.options {
603            let value_str = match value {
604                Value::String(s) => s.clone(),
605                Value::Bool(b) => b.to_string(),
606                Value::Number(n) => n.to_string(),
607                Value::Null => "null".to_string(),
608                other => serde_json::to_string(other).unwrap_or_default(),
609            };
610            lines.push(format!("  {}: {}", key, value_str));
611        }
612    }
613
614    lines.join("\n")
615}
616
617fn record_json_with_enrichments(
618    record: &ExecutionRecord,
619    current_time: Option<&str>,
620    process_ids: Option<&Value>,
621) -> Value {
622    let mut json = record.to_json();
623    if let Value::Object(map) = &mut json {
624        if let Some(process_ids) = process_ids {
625            map.insert("processIds".to_string(), process_ids.clone());
626        }
627        if let Some(ct) = current_time {
628            map.insert("currentTime".to_string(), Value::String(ct.to_string()));
629        }
630    }
631    json
632}
633
634/// Format execution record based on format type
635pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
636    format_record_with_current_time(record, format, None)
637}
638
639/// Same as [`format_record`] but the output includes `currentTime` when a
640/// value is supplied. Use this from [`query_status`] so all three formats
641/// stay in sync.
642pub fn format_record_with_current_time(
643    record: &ExecutionRecord,
644    format: &str,
645    current_time: Option<&str>,
646) -> Result<String, String> {
647    format_record_with_enrichments(record, format, current_time, None)
648}
649
650fn format_record_with_enrichments(
651    record: &ExecutionRecord,
652    format: &str,
653    current_time: Option<&str>,
654    process_ids: Option<&Value>,
655) -> Result<String, String> {
656    match format {
657        "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
658            record,
659            current_time,
660            process_ids,
661        )),
662        "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
663            record,
664            current_time,
665            process_ids,
666        ))
667        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
668        "text" => Ok(format_record_as_text_with_enrichments(
669            record,
670            current_time,
671            process_ids,
672        )),
673        _ => Err(format!("Unknown output format: {}", format)),
674    }
675}
676
677fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
678    records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
679}
680
681fn indent_block(block: &str, spaces: usize) -> String {
682    let prefix = " ".repeat(spaces);
683    block
684        .lines()
685        .map(|line| format!("{}{}", prefix, line))
686        .collect::<Vec<_>>()
687        .join("\n")
688}
689
690/// Format execution records as a Links Notation list.
691pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
692    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
693    let process_ids = vec![None; records.len()];
694    format_record_list_as_links_notation_with_current_times(records, &current_times, &process_ids)
695}
696
697fn format_record_list_as_links_notation_with_current_times(
698    records: &[ExecutionRecord],
699    current_times: &[Option<String>],
700    process_ids: &[Option<Value>],
701) -> String {
702    let mut lines = vec![
703        "executions".to_string(),
704        format!("  count {}", records.len()),
705    ];
706
707    if records.is_empty() {
708        lines.push("  records ()".to_string());
709        return lines.join("\n");
710    }
711
712    lines.push("  records".to_string());
713    for ((record, current_time), process_ids) in records
714        .iter()
715        .zip(current_times.iter())
716        .zip(process_ids.iter())
717    {
718        let block = format_record_as_links_notation_with_enrichments(
719            record,
720            current_time.as_deref(),
721            process_ids.as_ref(),
722        );
723        lines.push(indent_block(&block, 4));
724    }
725
726    lines.join("\n")
727}
728
729/// Format execution records as human-readable text.
730pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
731    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
732    let process_ids = vec![None; records.len()];
733    format_record_list_as_text_with_current_times(records, &current_times, &process_ids)
734}
735
736fn format_record_list_as_text_with_current_times(
737    records: &[ExecutionRecord],
738    current_times: &[Option<String>],
739    process_ids: &[Option<Value>],
740) -> String {
741    let mut lines = vec![
742        "Executions".to_string(),
743        "=".repeat(50),
744        format!("Count: {}", records.len()),
745    ];
746
747    for ((record, current_time), process_ids) in records
748        .iter()
749        .zip(current_times.iter())
750        .zip(process_ids.iter())
751    {
752        lines.push(String::new());
753        lines.push(format_record_as_text_with_enrichments(
754            record,
755            current_time.as_deref(),
756            process_ids.as_ref(),
757        ));
758    }
759
760    lines.join("\n")
761}
762
763fn record_list_json_with_current_times(
764    records: &[ExecutionRecord],
765    current_times: &[Option<String>],
766    process_ids: &[Option<Value>],
767) -> Value {
768    let executions: Vec<Value> = records
769        .iter()
770        .zip(current_times.iter())
771        .zip(process_ids.iter())
772        .map(|((record, current_time), process_ids)| {
773            record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
774        })
775        .collect();
776
777    serde_json::json!({
778        "count": records.len(),
779        "executions": executions,
780    })
781}
782
783/// Format execution records based on format type.
784pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
785    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
786    let process_ids = vec![None; records.len()];
787    format_record_list_with_current_times(records, format, &current_times, &process_ids)
788}
789
790fn format_record_list_with_current_times(
791    records: &[ExecutionRecord],
792    format: &str,
793    current_times: &[Option<String>],
794    process_ids: &[Option<Value>],
795) -> Result<String, String> {
796    match format {
797        "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
798            records,
799            current_times,
800            process_ids,
801        )),
802        "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
803            records,
804            current_times,
805            process_ids,
806        ))
807        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
808        "text" => Ok(format_record_list_as_text_with_current_times(
809            records,
810            current_times,
811            process_ids,
812        )),
813        _ => Err(format!("Unknown output format: {}", format)),
814    }
815}
816
817/// Query result from status lookup
818pub struct StatusQueryResult {
819    pub success: bool,
820    pub output: Option<String>,
821    pub error: Option<String>,
822}
823
824/// Handle execution list query and return the result
825pub fn list_executions(
826    store: Option<&ExecutionStore>,
827    output_format: Option<&str>,
828) -> StatusQueryResult {
829    list_executions_filtered(store, output_format, false)
830}
831
832/// Handle execution list query, optionally keeping only running executions.
833///
834/// `--list --running` reports the reconciled status, not the stored one: a
835/// record whose session already died is dropped even if the store still says
836/// "executing" (issue #162).
837pub fn list_executions_filtered(
838    store: Option<&ExecutionStore>,
839    output_format: Option<&str>,
840    running_only: bool,
841) -> StatusQueryResult {
842    let store = match store {
843        Some(s) => s,
844        None => {
845            return StatusQueryResult {
846                success: false,
847                output: None,
848                error: Some("Execution tracking is disabled.".to_string()),
849            }
850        }
851    };
852
853    let mut records: Vec<ExecutionRecord> =
854        store.get_all().iter().map(enrich_detached_status).collect();
855    if running_only {
856        records.retain(|record| record.status == ExecutionStatus::Executing);
857    }
858    sort_records_by_start_time_desc(&mut records);
859    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
860    let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
861    let format = output_format.unwrap_or("links-notation");
862
863    match format_record_list_with_current_times(&records, format, &current_times, &process_ids) {
864        Ok(output) => StatusQueryResult {
865            success: true,
866            output: Some(output),
867            error: None,
868        },
869        Err(e) => StatusQueryResult {
870            success: false,
871            output: None,
872            error: Some(e),
873        },
874    }
875}
876
877/// Handle status query and return the result
878pub fn query_status(
879    store: Option<&ExecutionStore>,
880    identifier: &str,
881    output_format: Option<&str>,
882) -> StatusQueryResult {
883    let store = match store {
884        Some(s) => s,
885        None => {
886            return StatusQueryResult {
887                success: false,
888                output: None,
889                error: Some("Execution tracking is disabled.".to_string()),
890            }
891        }
892    };
893
894    let record = match store.get(identifier) {
895        Some(r) => r,
896        None => {
897            return StatusQueryResult {
898                success: false,
899                output: None,
900                error: Some(format!(
901                    "No execution found with UUID or session name: {}",
902                    identifier
903                )),
904            }
905        }
906    };
907
908    // Enrich detached execution status with live session check
909    let enriched = enrich_detached_status(&record);
910    // Attach currentTime so callers can see how long an executing command has been running
911    let current_time = attach_current_time(&enriched);
912    let process_ids = collect_process_ids(&enriched);
913
914    let format = output_format.unwrap_or("links-notation");
915    match format_record_with_enrichments(
916        &enriched,
917        format,
918        current_time.as_deref(),
919        process_ids.as_ref(),
920    ) {
921        Ok(output) => StatusQueryResult {
922            success: true,
923            output: Some(output),
924            error: None,
925        },
926        Err(e) => StatusQueryResult {
927            success: false,
928            output: None,
929            error: Some(e),
930        },
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use crate::execution_store::ExecutionRecordOptions;
938    use serde_json::json;
939
940    fn executing_record() -> ExecutionRecord {
941        ExecutionRecord::with_options(ExecutionRecordOptions {
942            command: "sleep 60".to_string(),
943            uuid: Some("issue-126-rust".to_string()),
944            pid: Some(667105),
945            status: Some(ExecutionStatus::Executing),
946            log_path: Some("/tmp/issue-126.log".to_string()),
947            start_time: Some("2026-04-23T10:00:00Z".to_string()),
948            working_directory: Some("/home/user".to_string()),
949            shell: Some("/bin/bash".to_string()),
950            platform: Some("linux".to_string()),
951            ..Default::default()
952        })
953    }
954
955    #[test]
956    fn links_notation_indents_nested_process_id_arrays() {
957        let process_ids = json!({
958            "wrapperPid": 667105,
959            "screenPid": 667120,
960            "commandPids": [667121, 667122],
961        });
962        let output = format_record_with_enrichments(
963            &executing_record(),
964            "links-notation",
965            Some("2026-04-23T10:10:13.042Z"),
966            Some(&process_ids),
967        )
968        .expect("links-notation should format");
969
970        assert!(
971            output.contains(
972                "      commandPids\n        (\n          667121\n          667122\n        )"
973            ),
974            "processIds should be a nested indented block, output: {}",
975            output
976        );
977        assert!(
978            !output.contains("\n(\n"),
979            "opening parenthesis must not start at column 1: {}",
980            output
981        );
982    }
983}