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