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_inspect_batch::with_docker_inspect_batch;
9use crate::execution_attempt::{read_attempt_activity, read_attempt_log_tail};
10use crate::execution_control::collect_process_ids;
11use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
12use crate::exit_reason::{
13    describe_exit_code, resolve_exit_reason, resolve_memory_exhaustion, CGROUP_OOM_EXIT_REASON,
14};
15use crate::isolation::isolation_log::FATAL_MARKER_TAIL_BYTES;
16use crate::output_blocks::{escape_for_links_notation, format_value_for_links_notation};
17use crate::status_footer::read_footer_from_log;
18use crate::status_probe::resolve_detached_status;
19use chrono::Utc;
20use serde_json::Value;
21
22/// Read the terminal exit code from the anchored footer at the end of a log.
23///
24/// Thin wrapper kept for compatibility: the footer itself is parsed by
25/// `status_footer`, which also returns the `Finished:` timestamp callers need
26/// for `end_time` (issue #170.2).
27pub fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
28    read_footer_from_log(log_path).exit_code
29}
30
31/// Enrich execution record with live session status and an exit reason hint.
32///
33/// The hint explains an otherwise opaque exit code (`139` with `oomKilled
34/// false` is the motivating case from issue #162) and is derived from the same
35/// log tail the footer scan already reads. It is purely additive: `status`,
36/// `exitCode` and `oomKilled` are never changed by it. The same tail also
37/// yields the `memoryExhausted` observation for consumers that key off
38/// `oomKilled` (issue #165).
39pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
40    let mut enriched =
41        crate::container_removal::attach_container_removal(resolve_detached_status(record));
42    if let Some(state) = crate::cpu_penalty_monitor::read_state(record) {
43        enriched.cpu_penalty = Some(state);
44    }
45    read_attempt_activity(&mut enriched);
46    enriched.attempt_history = enrich_attempt_history(record);
47    let tail = crate::exit_evidence::record_tail(
48        &enriched,
49        read_attempt_log_tail(&enriched, FATAL_MARKER_TAIL_BYTES),
50    );
51    attach_exit_diagnostics(enriched, tail.as_deref())
52}
53
54fn attach_exit_diagnostics(mut enriched: ExecutionRecord, tail: Option<&str>) -> ExecutionRecord {
55    if enriched.status != ExecutionStatus::Executed {
56        return enriched;
57    }
58    let oom_kills = enriched.cgroup_memory.and_then(|memory| memory.oom_kills);
59    // A record finalized before #180 may carry the cgroup OOM reason for an
60    // ordinary exit (the sticky flag was blamed for exit 0/1): re-derive it.
61    if let Some(reason) =
62        resolve_exit_reason(enriched.exit_code, tail, enriched.oom_killed, oom_kills)
63    {
64        enriched.exit_reason = Some(reason);
65    } else if enriched.exit_reason.as_deref() == Some(CGROUP_OOM_EXIT_REASON) {
66        enriched.exit_reason = None;
67    }
68    if enriched
69        .memory_exhausted_reason
70        .as_deref()
71        .is_some_and(|s| {
72            s.starts_with("Docker reported")
73                || s.starts_with("cgroup memory.events")
74                || s.starts_with("Exit-time evidence")
75        })
76    {
77        enriched.memory_exhausted = None;
78        enriched.memory_exhausted_reason = None;
79    }
80    if enriched.memory_exhausted.is_none() {
81        if let Some(memory) =
82            resolve_memory_exhaustion(enriched.exit_code, tail, enriched.oom_killed, oom_kills)
83        {
84            enriched.memory_exhausted = Some(memory.memory_exhausted);
85            enriched.memory_exhausted_reason = Some(memory.memory_exhausted_reason);
86        }
87    }
88    enriched
89}
90
91fn enrich_attempt_history(record: &ExecutionRecord) -> Vec<Value> {
92    let mut base = record.clone();
93    base.attempt_history.clear();
94    record
95        .attempt_history
96        .iter()
97        .enumerate()
98        .map(|(index, attempt)| {
99            let mut historical = base.clone();
100            historical.status = if attempt.get("status").and_then(Value::as_str) == Some("executed")
101            {
102                ExecutionStatus::Executed
103            } else {
104                ExecutionStatus::Executing
105            };
106            historical.exit_code = attempt
107                .get("exitCode")
108                .and_then(Value::as_i64)
109                .and_then(|n| i32::try_from(n).ok());
110            historical.oom_killed = attempt.get("oomKilled").and_then(Value::as_bool);
111            historical.cgroup_memory = attempt
112                .get("cgroupMemory")
113                .and_then(|v| serde_json::from_value(v.clone()).ok());
114            historical.exit_reason = attempt
115                .get("exitReason")
116                .and_then(Value::as_str)
117                .map(str::to_string);
118            historical.memory_exhausted = attempt.get("memoryExhausted").and_then(Value::as_bool);
119            historical.memory_exhausted_reason = attempt
120                .get("memoryExhaustedReason")
121                .and_then(Value::as_str)
122                .map(str::to_string);
123            historical.options.remove("exitEvidence");
124            if let Some(evidence) = attempt.get("exitEvidence") {
125                historical
126                    .options
127                    .insert("exitEvidence".into(), evidence.clone());
128            }
129            let end = if let Some(next) = record.attempt_history.get(index + 1) {
130                next.get("logOffset").and_then(Value::as_u64)
131            } else {
132                record.attempt.as_ref().and_then(|a| a.log_offset)
133            };
134            let tail = attempt
135                .get("logOffset")
136                .and_then(Value::as_u64)
137                .zip(end)
138                .filter(|(start, end)| end >= start)
139                .and_then(|(start, end)| {
140                    crate::isolation::isolation_log::read_log_tail_range(
141                        &record.log_path,
142                        FATAL_MARKER_TAIL_BYTES,
143                        start,
144                        end,
145                    )
146                });
147            let tail = crate::exit_evidence::record_tail(&historical, tail);
148            let enriched = attach_exit_diagnostics(historical, tail.as_deref()).to_json();
149            let mut output = attempt.clone();
150            for key in ["exitReason", "memoryExhausted", "memoryExhaustedReason"] {
151                if let Some(object) = output.as_object_mut() {
152                    object.remove(key);
153                }
154                if let Some(value) = enriched.get(key) {
155                    output[key] = value.clone();
156                }
157            }
158            output
159        })
160        .collect()
161}
162
163/// Compute a `currentTime` value for a record if its status is `executing`.
164/// Returns `None` for completed records. Wrapping this in a helper makes it
165/// easy to attach the same timestamp to all output formats and to test the
166/// behavior deterministically.
167pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
168    if record.status == ExecutionStatus::Executing {
169        Some(Utc::now().to_rfc3339())
170    } else {
171        None
172    }
173}
174
175/// Format execution record as Links Notation (indented style)
176/// Uses nested Links notation for object values (like options) instead of JSON
177///
178/// Output format:
179/// ```text
180/// <uuid>
181///   <key> <value>
182///   options
183///     <nested_key> <nested_value>
184///   ...
185/// ```
186pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
187    format_record_as_links_notation_with_current_time(record, None)
188}
189
190/// Same as [`format_record_as_links_notation`] but injects a `currentTime`
191/// field (right after `startTime`) when a value is supplied.
192pub fn format_record_as_links_notation_with_current_time(
193    record: &ExecutionRecord,
194    current_time: Option<&str>,
195) -> String {
196    format_record_as_links_notation_with_enrichments(record, current_time, None, &[])
197}
198
199fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
200    let prefix = " ".repeat(indent);
201    if values.is_empty() {
202        lines.push(format!("{}()", prefix));
203        return;
204    }
205
206    lines.push(format!("{}(", prefix));
207    for value in values {
208        match value {
209            Value::Array(nested) => append_links_array(lines, nested, indent + 2),
210            Value::Object(map) => {
211                for (child_key, child_value) in map {
212                    if !child_value.is_null() {
213                        append_links_value(lines, child_key, child_value, indent + 2);
214                    }
215                }
216            }
217            _ => lines.push(format!(
218                "{}{}",
219                " ".repeat(indent + 2),
220                format_value_for_links_notation(value)
221            )),
222        }
223    }
224    lines.push(format!("{})", prefix));
225}
226
227fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
228    let prefix = " ".repeat(indent);
229    match value {
230        Value::Object(map) => {
231            if map.is_empty() {
232                return;
233            }
234            lines.push(format!("{}{}", prefix, key));
235            for (child_key, child_value) in map {
236                if !child_value.is_null() {
237                    append_links_value(lines, child_key, child_value, indent + 4);
238                }
239            }
240        }
241        Value::Array(values) => {
242            lines.push(format!("{}{}", prefix, key));
243            append_links_array(lines, values, indent + 2);
244        }
245        _ => lines.push(format!(
246            "{}{} {}",
247            prefix,
248            key,
249            format_value_for_links_notation(value)
250        )),
251    }
252}
253
254fn format_record_as_links_notation_with_enrichments(
255    record: &ExecutionRecord,
256    current_time: Option<&str>,
257    process_ids: Option<&Value>,
258    identity: &[(String, Value)],
259) -> String {
260    let json = record.to_json();
261    let mut lines = vec![record.uuid.clone()];
262
263    if let Value::Object(map) = json {
264        for (key, value) in map {
265            if !value.is_null() {
266                if key == "options" {
267                    // Nested option values (launchOwner, exitEvidence,
268                    // resolvedLimits…) are rendered as nested Links notation
269                    // instead of quoted JSON (issue #226).
270                    if let Value::Object(opts) = &value {
271                        if !opts.is_empty() {
272                            lines.push("  options".to_string());
273                            for (opt_key, opt_value) in opts {
274                                if !opt_value.is_null() {
275                                    append_links_value(&mut lines, opt_key, opt_value, 4);
276                                }
277                            }
278                        }
279                    }
280                } else if value.is_object() || value.is_array() {
281                    // Nested records such as `cgroupMemory` (issue #182), in
282                    // the same layout as the JavaScript formatter.
283                    append_links_value(&mut lines, &key, &value, 2);
284                } else {
285                    let formatted_value = match &value {
286                        Value::String(s) => escape_for_links_notation(s),
287                        Value::Bool(b) => b.to_string(),
288                        Value::Number(n) => n.to_string(),
289                        Value::Null => "null".to_string(),
290                        Value::Object(_) | Value::Array(_) => {
291                            // For other complex types, use nested format
292                            format_value_for_links_notation(&value)
293                        }
294                    };
295                    lines.push(format!("  {} {}", key, formatted_value));
296                }
297            }
298
299            // Names and the continuation chain follow the UUID (issue #231).
300            if key == "uuid" {
301                for (field, value) in identity {
302                    append_links_value(&mut lines, field, value, 2);
303                }
304            }
305
306            // Insert processIds right after pid so status output groups process
307            // identity with the wrapper PID already present in older output.
308            if key == "pid" {
309                if let Some(process_ids) = process_ids {
310                    append_links_value(&mut lines, "processIds", process_ids, 2);
311                }
312            }
313
314            // Insert currentTime right after startTime for readability
315            if key == "startTime" {
316                if let Some(ct) = current_time {
317                    lines.push(format!("  currentTime {}", escape_for_links_notation(ct)));
318                }
319            }
320        }
321    }
322
323    lines.join("\n")
324}
325
326/// Format execution record as human-readable text
327pub fn format_record_as_text(record: &ExecutionRecord) -> String {
328    format_record_as_text_with_current_time(record, None)
329}
330
331/// Same as [`format_record_as_text`] but adds a `Current Time:` line right
332/// after `Start Time:` when a value is supplied.
333pub fn format_record_as_text_with_current_time(
334    record: &ExecutionRecord,
335    current_time: Option<&str>,
336) -> String {
337    format_record_as_text_with_enrichments(record, current_time, None, &[])
338}
339
340fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
341    let Value::Object(map) = process_ids else {
342        return;
343    };
344    if map.is_empty() {
345        return;
346    }
347
348    lines.push("Process IDs:".to_string());
349    for (key, value) in map {
350        let value_str = match value {
351            Value::String(s) => s.clone(),
352            Value::Bool(b) => b.to_string(),
353            Value::Number(n) => n.to_string(),
354            Value::Null => "null".to_string(),
355            other => serde_json::to_string(other).unwrap_or_default(),
356        };
357        lines.push(format!("  {}: {}", key, value_str));
358    }
359}
360
361fn format_record_as_text_with_enrichments(
362    record: &ExecutionRecord,
363    current_time: Option<&str>,
364    process_ids: Option<&Value>,
365    identity: &[(String, Value)],
366) -> String {
367    let exit_code_str = record
368        .exit_code
369        // Decoded through the same helper the completion watcher generates its
370        // `case` table from, so `137` reads as `137 (SIGKILL - 128+9)` here and
371        // in the log post-mortem alike (issue #171.4).
372        .map(|c| describe_exit_code(Some(c)).text)
373        .unwrap_or_else(|| "N/A".to_string());
374    let pid_str = record
375        .pid
376        .map(|p| p.to_string())
377        .unwrap_or_else(|| "N/A".to_string());
378    let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
379
380    let mut lines = vec![
381        "Execution Status".to_string(),
382        "=".repeat(50),
383        format!("UUID:              {}", record.uuid),
384        format!("Status:            {}", record.status),
385        format!("Command:           {}", record.command),
386    ];
387    lines.extend(crate::execution_chain::identity_text_lines(
388        record, identity,
389    ));
390    lines.push(format!("Exit Code:         {}", exit_code_str));
391    if let Some(oom_killed) = record.oom_killed {
392        lines.push(format!("OOM Killed:        {}", oom_killed));
393    }
394    if let Some(ref exit_reason) = record.exit_reason {
395        lines.push(format!("Exit Reason:       {}", exit_reason));
396    }
397    if let Some(memory_exhausted) = record.memory_exhausted {
398        lines.push(format!("Memory Exhausted:  {}", memory_exhausted));
399    }
400    if let Some(ref reason) = record.memory_exhausted_reason {
401        lines.push(format!("Memory Evidence:   {}", reason));
402    }
403    // cgroup v2 counters the detached watcher sampled (issue #182).
404    if let Some(ref memory) = record.cgroup_memory {
405        lines.push(format!(
406            "Cgroup Memory:     {}",
407            crate::cgroup_memory::format_cgroup_memory(memory)
408        ));
409    }
410    if let Some(ref state) = record.cpu_penalty {
411        lines.push(format!("CPU Penalty:       {}", state));
412    }
413    lines.push(format!("PID:               {}", pid_str));
414    if let Some(process_ids) = process_ids {
415        append_text_process_ids(&mut lines, process_ids);
416    }
417    lines.extend([
418        format!("Working Directory: {}", record.working_directory),
419        format!("Shell:             {}", record.shell),
420        format!("Platform:          {}", record.platform),
421        format!("Start Time:        {}", record.start_time),
422    ]);
423    if let Some(ct) = current_time {
424        lines.push(format!("Current Time:      {}", ct));
425    }
426    lines.push(format!("End Time:          {}", end_time_str));
427    if let Some(ref source) = record.end_time_source {
428        // Where `End Time` came from: a real finish time, or merely when
429        // `start` noticed the execution was over (issue #170.2).
430        lines.push(format!("End Time Source:   {}", source));
431    }
432    if let Some(ref started) = record.container_started_at {
433        lines.push(format!("Container Started: {}", started));
434    }
435    if let Some(ref detected) = record.stale_detected_at {
436        lines.push(format!("Stale Detected At: {}", detected));
437    }
438    if let Some(ref state) = record.container_state {
439        // The container no longer exists; `containerId` is history (issue #225).
440        lines.push(format!("Container State:   {}", state));
441        lines.push(format!(
442            "Container Removed: {}",
443            record
444                .container_removed_observed_at
445                .as_deref()
446                .unwrap_or("N/A")
447        ));
448    }
449    lines.push(format!("Log Path:          {}", record.log_path));
450    if let Some(attempt) = &record.attempt {
451        lines.extend([
452            format!("Attempt:           {}", attempt.number),
453            format!("Attempt Started:   {}", attempt.started_at),
454            format!(
455                "Attempt Log Offset: {}",
456                attempt
457                    .log_offset
458                    .map(|offset| offset.to_string())
459                    .unwrap_or_else(|| "N/A".into())
460            ),
461            format!(
462                "Launch Accepted:   {}",
463                attempt.launch_accepted_at.as_deref().unwrap_or("N/A")
464            ),
465            format!(
466                "Watcher Attached:  {}",
467                attempt.watcher_attached_at.as_deref().unwrap_or("N/A")
468            ),
469            format!(
470                "Last Output:       {}",
471                attempt.last_output_at.as_deref().unwrap_or("N/A")
472            ),
473        ]);
474    }
475
476    // Format options as nested list instead of JSON
477    if !record.options.is_empty() {
478        lines.push("Options:".to_string());
479        // Sorted, like the links-notation and JSON output, so the text format
480        // is deterministic too.
481        let mut options: Vec<_> = record.options.iter().collect();
482        options.sort_by(|a, b| a.0.cmp(b.0));
483        for (key, value) in options {
484            let value_str = match value {
485                Value::String(s) => s.clone(),
486                Value::Bool(b) => b.to_string(),
487                Value::Number(n) => n.to_string(),
488                Value::Null => "null".to_string(),
489                other => serde_json::to_string(other).unwrap_or_default(),
490            };
491            lines.push(format!("  {}: {}", key, value_str));
492        }
493    }
494
495    lines.join("\n")
496}
497
498fn record_json_with_enrichments(
499    record: &ExecutionRecord,
500    current_time: Option<&str>,
501    process_ids: Option<&Value>,
502    identity: &[(String, Value)],
503) -> Value {
504    let mut json = record.to_json();
505    if let Value::Object(map) = &mut json {
506        map.extend(identity.iter().cloned());
507        if let Some(process_ids) = process_ids {
508            map.insert("processIds".to_string(), process_ids.clone());
509        }
510        if let Some(ct) = current_time {
511            map.insert("currentTime".to_string(), Value::String(ct.to_string()));
512        }
513    }
514    json
515}
516
517/// Format execution record based on format type
518pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
519    format_record_with_current_time(record, format, None)
520}
521
522/// Same as [`format_record`] but the output includes `currentTime` when a
523/// value is supplied. Use this from [`query_status`] so all three formats
524/// stay in sync.
525pub fn format_record_with_current_time(
526    record: &ExecutionRecord,
527    format: &str,
528    current_time: Option<&str>,
529) -> Result<String, String> {
530    format_record_with_enrichments(record, format, current_time, None, &[])
531}
532
533fn format_record_with_enrichments(
534    record: &ExecutionRecord,
535    format: &str,
536    current_time: Option<&str>,
537    process_ids: Option<&Value>,
538    identity: &[(String, Value)],
539) -> Result<String, String> {
540    match format {
541        "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
542            record,
543            current_time,
544            process_ids,
545            identity,
546        )),
547        "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
548            record,
549            current_time,
550            process_ids,
551            identity,
552        ))
553        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
554        "text" => Ok(format_record_as_text_with_enrichments(
555            record,
556            current_time,
557            process_ids,
558            identity,
559        )),
560        _ => Err(format!("Unknown output format: {}", format)),
561    }
562}
563
564fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
565    records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
566}
567
568fn indent_block(block: &str, spaces: usize) -> String {
569    let prefix = " ".repeat(spaces);
570    block
571        .lines()
572        .map(|line| format!("{}{}", prefix, line))
573        .collect::<Vec<_>>()
574        .join("\n")
575}
576
577/// Format execution records as a Links Notation list.
578pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
579    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
580    let process_ids = vec![None; records.len()];
581    format_record_list_as_links_notation_with_current_times(records, &current_times, &process_ids)
582}
583
584fn format_record_list_as_links_notation_with_current_times(
585    records: &[ExecutionRecord],
586    current_times: &[Option<String>],
587    process_ids: &[Option<Value>],
588) -> String {
589    let mut lines = vec![
590        "executions".to_string(),
591        format!("  count {}", records.len()),
592    ];
593
594    if records.is_empty() {
595        lines.push("  records ()".to_string());
596        return lines.join("\n");
597    }
598
599    lines.push("  records".to_string());
600    for ((record, current_time), process_ids) in records
601        .iter()
602        .zip(current_times.iter())
603        .zip(process_ids.iter())
604    {
605        let block = format_record_as_links_notation_with_enrichments(
606            record,
607            current_time.as_deref(),
608            process_ids.as_ref(),
609            &[],
610        );
611        lines.push(indent_block(&block, 4));
612    }
613
614    lines.join("\n")
615}
616
617/// Format execution records as human-readable text.
618pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
619    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
620    let process_ids = vec![None; records.len()];
621    format_record_list_as_text_with_current_times(records, &current_times, &process_ids)
622}
623
624fn format_record_list_as_text_with_current_times(
625    records: &[ExecutionRecord],
626    current_times: &[Option<String>],
627    process_ids: &[Option<Value>],
628) -> String {
629    let mut lines = vec![
630        "Executions".to_string(),
631        "=".repeat(50),
632        format!("Count: {}", records.len()),
633    ];
634
635    for ((record, current_time), process_ids) in records
636        .iter()
637        .zip(current_times.iter())
638        .zip(process_ids.iter())
639    {
640        lines.push(String::new());
641        lines.push(format_record_as_text_with_enrichments(
642            record,
643            current_time.as_deref(),
644            process_ids.as_ref(),
645            &[],
646        ));
647    }
648
649    lines.join("\n")
650}
651
652fn record_list_json_with_current_times(
653    records: &[ExecutionRecord],
654    current_times: &[Option<String>],
655    process_ids: &[Option<Value>],
656) -> Value {
657    let executions: Vec<Value> = records
658        .iter()
659        .zip(current_times.iter())
660        .zip(process_ids.iter())
661        .map(|((record, current_time), process_ids)| {
662            record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref(), &[])
663        })
664        .collect();
665
666    serde_json::json!({
667        "count": records.len(),
668        "executions": executions,
669    })
670}
671
672/// Format execution records based on format type.
673pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
674    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
675    let process_ids = vec![None; records.len()];
676    format_record_list_with_current_times(records, format, &current_times, &process_ids)
677}
678
679fn format_record_list_with_current_times(
680    records: &[ExecutionRecord],
681    format: &str,
682    current_times: &[Option<String>],
683    process_ids: &[Option<Value>],
684) -> Result<String, String> {
685    match format {
686        "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
687            records,
688            current_times,
689            process_ids,
690        )),
691        "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
692            records,
693            current_times,
694            process_ids,
695        ))
696        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
697        "text" => Ok(format_record_list_as_text_with_current_times(
698            records,
699            current_times,
700            process_ids,
701        )),
702        _ => Err(format!("Unknown output format: {}", format)),
703    }
704}
705
706/// Query result from status lookup
707pub struct StatusQueryResult {
708    pub success: bool,
709    pub output: Option<String>,
710    pub error: Option<String>,
711}
712
713/// Handle execution list query and return the result
714pub fn list_executions(
715    store: Option<&ExecutionStore>,
716    output_format: Option<&str>,
717) -> StatusQueryResult {
718    list_executions_filtered(store, output_format, false)
719}
720
721/// Handle execution list query, optionally keeping only running executions.
722///
723/// `--list --running` reports the reconciled status, not the stored one: a
724/// record whose session already died is dropped even if the store still says
725/// "executing" (issue #162).
726pub fn list_executions_filtered(
727    store: Option<&ExecutionStore>,
728    output_format: Option<&str>,
729    running_only: bool,
730) -> StatusQueryResult {
731    let store = match store {
732        Some(s) => s,
733        None => {
734            return StatusQueryResult {
735                success: false,
736                output: None,
737                error: Some("Execution tracking is disabled.".to_string()),
738            }
739        }
740    };
741
742    let all = store.get_all();
743    // One docker inspect for every listed container (issue #243).
744    let (records, current_times, process_ids) = with_docker_inspect_batch(&all, || {
745        let mut records: Vec<ExecutionRecord> = all.iter().map(enrich_detached_status).collect();
746        if running_only {
747            records.retain(|record| record.status == ExecutionStatus::Executing);
748        }
749        sort_records_by_start_time_desc(&mut records);
750        let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
751        let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
752        (records, current_times, process_ids)
753    });
754    let format = output_format.unwrap_or("links-notation");
755
756    match format_record_list_with_current_times(&records, format, &current_times, &process_ids) {
757        Ok(output) => StatusQueryResult {
758            success: true,
759            output: Some(output),
760            error: None,
761        },
762        Err(e) => StatusQueryResult {
763            success: false,
764            output: None,
765            error: Some(e),
766        },
767    }
768}
769
770/// Handle status query and return the result
771pub fn query_status(
772    store: Option<&ExecutionStore>,
773    identifier: &str,
774    output_format: Option<&str>,
775) -> StatusQueryResult {
776    let store = match store {
777        Some(s) => s,
778        None => {
779            return StatusQueryResult {
780                success: false,
781                output: None,
782                error: Some("Execution tracking is disabled.".to_string()),
783            }
784        }
785    };
786
787    let record = match store.get(identifier) {
788        Some(r) => r,
789        None => {
790            return StatusQueryResult {
791                success: false,
792                output: None,
793                error: Some(format!(
794                    "No execution found with UUID or session name: {}",
795                    identifier
796                )),
797            }
798        }
799    };
800
801    // Enrich detached execution status with live session check; its docker
802    // probes are answered by one inspect (issue #243).
803    let (enriched, process_ids) = with_docker_inspect_batch(std::slice::from_ref(&record), || {
804        let enriched = enrich_detached_status(&record);
805        crate::container_removal::persist_container_removal(store, &record, &enriched);
806        let process_ids = collect_process_ids(&enriched);
807        (enriched, process_ids)
808    });
809    // Attach currentTime so callers can see how long an executing command has been running
810    let current_time = attach_current_time(&enriched);
811    let identity = crate::execution_chain::status_identity(&enriched, Some(store));
812
813    let format = output_format.unwrap_or("links-notation");
814    match format_record_with_enrichments(
815        &enriched,
816        format,
817        current_time.as_deref(),
818        process_ids.as_ref(),
819        &identity,
820    ) {
821        Ok(output) => StatusQueryResult {
822            success: true,
823            output: Some(output),
824            error: None,
825        },
826        Err(e) => StatusQueryResult {
827            success: false,
828            output: None,
829            error: Some(e),
830        },
831    }
832}
833
834#[cfg(test)]
835mod tests {
836    use super::*;
837    use crate::execution_store::ExecutionRecordOptions;
838    use serde_json::json;
839
840    fn executing_record() -> ExecutionRecord {
841        ExecutionRecord::with_options(ExecutionRecordOptions {
842            command: "sleep 60".to_string(),
843            uuid: Some("issue-126-rust".to_string()),
844            pid: Some(667105),
845            status: Some(ExecutionStatus::Executing),
846            log_path: Some("/tmp/issue-126.log".to_string()),
847            start_time: Some("2026-04-23T10:00:00Z".to_string()),
848            working_directory: Some("/home/user".to_string()),
849            shell: Some("/bin/bash".to_string()),
850            platform: Some("linux".to_string()),
851            ..Default::default()
852        })
853    }
854
855    #[test]
856    fn links_notation_indents_nested_process_id_arrays() {
857        let process_ids = json!({
858            "wrapperPid": 667105,
859            "screenPid": 667120,
860            "commandPids": [667121, 667122],
861        });
862        let output = format_record_with_enrichments(
863            &executing_record(),
864            "links-notation",
865            Some("2026-04-23T10:10:13.042Z"),
866            Some(&process_ids),
867            &[],
868        )
869        .expect("links-notation should format");
870
871        assert!(
872            output.contains(
873                "      commandPids\n        (\n          667121\n          667122\n        )"
874            ),
875            "processIds should be a nested indented block, output: {}",
876            output
877        );
878        assert!(
879            !output.contains("\n(\n"),
880            "opening parenthesis must not start at column 1: {}",
881            output
882        );
883    }
884}