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