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