1use crate::docker_cleanup::docker_command;
9use crate::execution_control::collect_process_ids;
10use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
11use crate::exit_reason::{resolve_exit_reason, resolve_memory_exhaustion};
12use crate::isolation::isolation_log::{read_log_tail, FATAL_MARKER_TAIL_BYTES, LOG_TAIL_BYTES};
13use crate::output_blocks::{escape_for_links_notation, format_value_for_links_notation};
14use serde_json::Value;
15use std::process::Command;
16
17#[derive(Clone, Copy)]
19struct DockerState {
20 running: bool,
21 exit_code: Option<i32>,
22 oom_killed: Option<bool>,
23}
24
25fn inspect_docker_state(session_name: &str) -> Option<DockerState> {
37 let output = Command::new(docker_command())
38 .args([
39 "inspect",
40 "-f",
41 "{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}",
42 session_name,
43 ])
44 .output()
45 .ok()?;
46 if !output.status.success() {
47 return None;
48 }
49 let stdout = String::from_utf8_lossy(&output.stdout);
50 let trimmed = stdout.trim();
51 if trimmed.is_empty() {
52 return None;
53 }
54 let mut parts = trimmed.split_whitespace();
55 let running = parts.next() == Some("true");
56 let exit_code = parts.next().and_then(|value| value.parse::<i32>().ok());
57 let oom_killed = parts.next().and_then(|value| match value {
58 "true" => Some(true),
59 "false" => Some(false),
60 _ => None,
61 });
62 Some(DockerState {
63 running,
64 exit_code,
65 oom_killed,
66 })
67}
68
69fn is_detached_docker_record(record: &ExecutionRecord) -> bool {
70 record.options.get("isolated").and_then(|v| v.as_str()) == Some("docker")
71 && record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached")
72 && record
73 .options
74 .get("sessionName")
75 .and_then(|v| v.as_str())
76 .is_some()
77}
78
79fn read_docker_state(record: &ExecutionRecord) -> Option<DockerState> {
80 if record.options.get("isolated")?.as_str()? != "docker" {
81 return None;
82 }
83 let session_name = record.options.get("sessionName")?.as_str()?;
84 inspect_docker_state(session_name)
85}
86
87fn backend_exit_code(docker_state: Option<DockerState>) -> Option<i32> {
94 let state = docker_state?;
95 if state.running {
96 None
97 } else {
98 state.exit_code
99 }
100}
101
102fn resolve_oom_observation(
111 record: &ExecutionRecord,
112 docker_state: Option<DockerState>,
113) -> Option<bool> {
114 let inspected = docker_state.and_then(|state| state.oom_killed);
115 if record.oom_killed == Some(true) || inspected == Some(true) {
116 return Some(true);
117 }
118 if record.oom_killed == Some(false) || inspected == Some(false) {
119 return Some(false);
120 }
121 None
122}
123
124pub fn is_detached_session_alive(record: &ExecutionRecord) -> Option<bool> {
127 let session_name = record.options.get("sessionName")?.as_str()?;
128 let isolation_mode = record.options.get("isolationMode")?.as_str()?;
129 let isolated = record.options.get("isolated")?.as_str()?;
130
131 if isolation_mode != "detached" {
132 return None;
133 }
134
135 match isolated {
136 "screen" => {
137 let output = Command::new("screen").args(["-ls"]).output().ok()?;
138 let stdout = String::from_utf8_lossy(&output.stdout);
139 Some(stdout.contains(session_name))
140 }
141 "tmux" => {
142 let status = Command::new("tmux")
143 .args(["has-session", "-t", session_name])
144 .output()
145 .ok()?;
146 Some(status.status.success())
147 }
148 "docker" => {
149 inspect_docker_state(session_name).map(|state| state.running)
154 }
155 "ssh" => {
156 #[cfg(unix)]
158 {
159 if let Some(pid) = record.pid {
160 let result = unsafe { libc::kill(pid as i32, 0) };
161 Some(result == 0)
162 } else {
163 None
164 }
165 }
166 #[cfg(not(unix))]
167 {
168 let _ = record.pid;
169 None
170 }
171 }
172 _ => None,
173 }
174}
175
176fn is_footer_separator_line(line: &str) -> bool {
177 line.len() >= 10 && line.chars().all(|c| c == '=')
178}
179
180pub fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
197 let tail = read_log_tail(log_path, LOG_TAIL_BYTES)?;
198 let lines: Vec<&str> = tail
199 .lines()
200 .map(|line| line.trim_end_matches('\r'))
201 .collect();
202 for index in (2..lines.len()).rev() {
203 let value = match lines[index].strip_prefix("Exit Code:") {
204 Some(value) => value,
205 None => continue,
206 };
207 if !lines[index - 1].starts_with("Finished:") || !is_footer_separator_line(lines[index - 2])
208 {
209 continue;
210 }
211 if let Ok(code) = value.trim().parse::<i32>() {
212 return Some(code);
213 }
214 }
215 None
216}
217
218pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
227 let mut enriched = resolve_detached_status(record);
228 if enriched.status != ExecutionStatus::Executed {
229 return enriched;
230 }
231 let tail = read_log_tail(&enriched.log_path, FATAL_MARKER_TAIL_BYTES);
232 if enriched.exit_reason.is_none() {
233 enriched.exit_reason =
234 resolve_exit_reason(enriched.exit_code, tail.as_deref(), enriched.oom_killed);
235 }
236 if enriched.memory_exhausted.is_none() {
237 if let Some(memory) =
238 resolve_memory_exhaustion(enriched.exit_code, tail.as_deref(), enriched.oom_killed)
239 {
240 enriched.memory_exhausted = Some(memory.memory_exhausted);
241 enriched.memory_exhausted_reason = Some(memory.memory_exhausted_reason);
242 }
243 }
244 enriched
245}
246
247fn resolve_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
252 let footer_exit = read_exit_code_from_log(&record.log_path);
253 let is_detached_docker = is_detached_docker_record(record);
254 let docker_state = if is_detached_docker {
255 read_docker_state(record)
256 } else {
257 None
258 };
259
260 let oom_killed = resolve_oom_observation(record, docker_state);
262 let clone_record = || {
263 let mut enriched = record.clone();
264 if oom_killed.is_some() {
265 enriched.oom_killed = oom_killed;
266 }
267 enriched
268 };
269
270 let alive = if is_detached_docker {
271 docker_state.map(|state| state.running)
272 } else {
273 is_detached_session_alive(record)
274 };
275
276 let alive = match alive {
277 Some(value) => value,
278 None => {
279 let is_detached =
287 record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached");
288 if is_detached && record.status == ExecutionStatus::Executing {
289 if footer_exit.is_some() {
290 let mut enriched = clone_record();
291 enriched.status = ExecutionStatus::Executed;
292 enriched.exit_code = footer_exit;
293 if enriched.end_time.is_none() {
294 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
295 }
296 return enriched;
297 }
298 if oom_killed == Some(true) {
299 let mut enriched = clone_record();
306 enriched.status = ExecutionStatus::Executed;
307 enriched.exit_code = Some(137);
308 if enriched.end_time.is_none() {
309 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
310 }
311 return enriched;
312 }
313 }
314 return clone_record();
315 }
316 };
317
318 let mut enriched = clone_record();
319
320 if alive && enriched.status == ExecutionStatus::Executed {
321 if enriched.exit_code.is_none() && footer_exit.is_none() {
328 enriched.status = ExecutionStatus::Executing;
330 enriched.exit_code = None;
331 enriched.end_time = None;
332 }
333 } else if !alive && enriched.status == ExecutionStatus::Executing {
335 enriched.status = ExecutionStatus::Executed;
343 if enriched.exit_code.is_none() {
344 enriched.exit_code = Some(
345 backend_exit_code(docker_state)
346 .or(footer_exit)
347 .or(if oom_killed == Some(true) {
348 Some(137)
349 } else {
350 None
351 })
352 .unwrap_or(-1),
353 );
354 }
355 if enriched.end_time.is_none() {
356 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
357 }
358 }
359
360 enriched
361}
362
363pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
368 if record.status == ExecutionStatus::Executing {
369 Some(chrono::Utc::now().to_rfc3339())
370 } else {
371 None
372 }
373}
374
375pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
387 format_record_as_links_notation_with_current_time(record, None)
388}
389
390pub fn format_record_as_links_notation_with_current_time(
393 record: &ExecutionRecord,
394 current_time: Option<&str>,
395) -> String {
396 format_record_as_links_notation_with_enrichments(record, current_time, None)
397}
398
399fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
400 let prefix = " ".repeat(indent);
401 if values.is_empty() {
402 lines.push(format!("{}()", prefix));
403 return;
404 }
405
406 lines.push(format!("{}(", prefix));
407 for value in values {
408 match value {
409 Value::Array(nested) => append_links_array(lines, nested, indent + 2),
410 Value::Object(map) => {
411 for (child_key, child_value) in map {
412 if !child_value.is_null() {
413 append_links_value(lines, child_key, child_value, indent + 2);
414 }
415 }
416 }
417 _ => lines.push(format!(
418 "{}{}",
419 " ".repeat(indent + 2),
420 format_value_for_links_notation(value)
421 )),
422 }
423 }
424 lines.push(format!("{})", prefix));
425}
426
427fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
428 let prefix = " ".repeat(indent);
429 match value {
430 Value::Object(map) => {
431 if map.is_empty() {
432 return;
433 }
434 lines.push(format!("{}{}", prefix, key));
435 for (child_key, child_value) in map {
436 if !child_value.is_null() {
437 append_links_value(lines, child_key, child_value, indent + 4);
438 }
439 }
440 }
441 Value::Array(values) => {
442 lines.push(format!("{}{}", prefix, key));
443 append_links_array(lines, values, indent + 2);
444 }
445 _ => lines.push(format!(
446 "{}{} {}",
447 prefix,
448 key,
449 format_value_for_links_notation(value)
450 )),
451 }
452}
453
454fn format_record_as_links_notation_with_enrichments(
455 record: &ExecutionRecord,
456 current_time: Option<&str>,
457 process_ids: Option<&Value>,
458) -> String {
459 let json = record.to_json();
460 let mut lines = vec![record.uuid.clone()];
461
462 if let Value::Object(map) = json {
463 for (key, value) in map {
464 if !value.is_null() {
465 if key == "options" {
466 if let Value::Object(opts) = &value {
468 if !opts.is_empty() {
469 lines.push(" options".to_string());
470 for (opt_key, opt_value) in opts {
471 if !opt_value.is_null() {
472 let formatted = format_value_for_links_notation(opt_value);
473 lines.push(format!(" {} {}", opt_key, formatted));
474 }
475 }
476 }
477 }
478 } else {
479 let formatted_value = match &value {
480 Value::String(s) => escape_for_links_notation(s),
481 Value::Bool(b) => b.to_string(),
482 Value::Number(n) => n.to_string(),
483 Value::Null => "null".to_string(),
484 Value::Object(_) | Value::Array(_) => {
485 format_value_for_links_notation(&value)
487 }
488 };
489 lines.push(format!(" {} {}", key, formatted_value));
490 }
491 }
492
493 if key == "pid" {
496 if let Some(process_ids) = process_ids {
497 append_links_value(&mut lines, "processIds", process_ids, 2);
498 }
499 }
500
501 if key == "startTime" {
503 if let Some(ct) = current_time {
504 lines.push(format!(" currentTime {}", escape_for_links_notation(ct)));
505 }
506 }
507 }
508 }
509
510 lines.join("\n")
511}
512
513pub fn format_record_as_text(record: &ExecutionRecord) -> String {
515 format_record_as_text_with_current_time(record, None)
516}
517
518pub fn format_record_as_text_with_current_time(
521 record: &ExecutionRecord,
522 current_time: Option<&str>,
523) -> String {
524 format_record_as_text_with_enrichments(record, current_time, None)
525}
526
527fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
528 let Value::Object(map) = process_ids else {
529 return;
530 };
531 if map.is_empty() {
532 return;
533 }
534
535 lines.push("Process IDs:".to_string());
536 for (key, value) in map {
537 let value_str = match value {
538 Value::String(s) => s.clone(),
539 Value::Bool(b) => b.to_string(),
540 Value::Number(n) => n.to_string(),
541 Value::Null => "null".to_string(),
542 other => serde_json::to_string(other).unwrap_or_default(),
543 };
544 lines.push(format!(" {}: {}", key, value_str));
545 }
546}
547
548fn format_record_as_text_with_enrichments(
549 record: &ExecutionRecord,
550 current_time: Option<&str>,
551 process_ids: Option<&Value>,
552) -> String {
553 let exit_code_str = record
554 .exit_code
555 .map(|c| c.to_string())
556 .unwrap_or_else(|| "N/A".to_string());
557 let pid_str = record
558 .pid
559 .map(|p| p.to_string())
560 .unwrap_or_else(|| "N/A".to_string());
561 let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
562
563 let mut lines = vec![
564 "Execution Status".to_string(),
565 "=".repeat(50),
566 format!("UUID: {}", record.uuid),
567 format!("Status: {}", record.status),
568 format!("Command: {}", record.command),
569 format!("Exit Code: {}", exit_code_str),
570 ];
571 if let Some(oom_killed) = record.oom_killed {
572 lines.push(format!("OOM Killed: {}", oom_killed));
573 }
574 if let Some(ref exit_reason) = record.exit_reason {
575 lines.push(format!("Exit Reason: {}", exit_reason));
576 }
577 if let Some(memory_exhausted) = record.memory_exhausted {
578 lines.push(format!("Memory Exhausted: {}", memory_exhausted));
579 }
580 if let Some(ref reason) = record.memory_exhausted_reason {
581 lines.push(format!("Memory Evidence: {}", reason));
582 }
583 lines.push(format!("PID: {}", pid_str));
584 if let Some(process_ids) = process_ids {
585 append_text_process_ids(&mut lines, process_ids);
586 }
587 lines.extend([
588 format!("Working Directory: {}", record.working_directory),
589 format!("Shell: {}", record.shell),
590 format!("Platform: {}", record.platform),
591 format!("Start Time: {}", record.start_time),
592 ]);
593 if let Some(ct) = current_time {
594 lines.push(format!("Current Time: {}", ct));
595 }
596 lines.push(format!("End Time: {}", end_time_str));
597 lines.push(format!("Log Path: {}", record.log_path));
598
599 if !record.options.is_empty() {
601 lines.push("Options:".to_string());
602 for (key, value) in &record.options {
603 let value_str = match value {
604 Value::String(s) => s.clone(),
605 Value::Bool(b) => b.to_string(),
606 Value::Number(n) => n.to_string(),
607 Value::Null => "null".to_string(),
608 other => serde_json::to_string(other).unwrap_or_default(),
609 };
610 lines.push(format!(" {}: {}", key, value_str));
611 }
612 }
613
614 lines.join("\n")
615}
616
617fn record_json_with_enrichments(
618 record: &ExecutionRecord,
619 current_time: Option<&str>,
620 process_ids: Option<&Value>,
621) -> Value {
622 let mut json = record.to_json();
623 if let Value::Object(map) = &mut json {
624 if let Some(process_ids) = process_ids {
625 map.insert("processIds".to_string(), process_ids.clone());
626 }
627 if let Some(ct) = current_time {
628 map.insert("currentTime".to_string(), Value::String(ct.to_string()));
629 }
630 }
631 json
632}
633
634pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
636 format_record_with_current_time(record, format, None)
637}
638
639pub fn format_record_with_current_time(
643 record: &ExecutionRecord,
644 format: &str,
645 current_time: Option<&str>,
646) -> Result<String, String> {
647 format_record_with_enrichments(record, format, current_time, None)
648}
649
650fn format_record_with_enrichments(
651 record: &ExecutionRecord,
652 format: &str,
653 current_time: Option<&str>,
654 process_ids: Option<&Value>,
655) -> Result<String, String> {
656 match format {
657 "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
658 record,
659 current_time,
660 process_ids,
661 )),
662 "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
663 record,
664 current_time,
665 process_ids,
666 ))
667 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
668 "text" => Ok(format_record_as_text_with_enrichments(
669 record,
670 current_time,
671 process_ids,
672 )),
673 _ => Err(format!("Unknown output format: {}", format)),
674 }
675}
676
677fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
678 records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
679}
680
681fn indent_block(block: &str, spaces: usize) -> String {
682 let prefix = " ".repeat(spaces);
683 block
684 .lines()
685 .map(|line| format!("{}{}", prefix, line))
686 .collect::<Vec<_>>()
687 .join("\n")
688}
689
690pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
692 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
693 let process_ids = vec![None; records.len()];
694 format_record_list_as_links_notation_with_current_times(records, ¤t_times, &process_ids)
695}
696
697fn format_record_list_as_links_notation_with_current_times(
698 records: &[ExecutionRecord],
699 current_times: &[Option<String>],
700 process_ids: &[Option<Value>],
701) -> String {
702 let mut lines = vec![
703 "executions".to_string(),
704 format!(" count {}", records.len()),
705 ];
706
707 if records.is_empty() {
708 lines.push(" records ()".to_string());
709 return lines.join("\n");
710 }
711
712 lines.push(" records".to_string());
713 for ((record, current_time), process_ids) in records
714 .iter()
715 .zip(current_times.iter())
716 .zip(process_ids.iter())
717 {
718 let block = format_record_as_links_notation_with_enrichments(
719 record,
720 current_time.as_deref(),
721 process_ids.as_ref(),
722 );
723 lines.push(indent_block(&block, 4));
724 }
725
726 lines.join("\n")
727}
728
729pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
731 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
732 let process_ids = vec![None; records.len()];
733 format_record_list_as_text_with_current_times(records, ¤t_times, &process_ids)
734}
735
736fn format_record_list_as_text_with_current_times(
737 records: &[ExecutionRecord],
738 current_times: &[Option<String>],
739 process_ids: &[Option<Value>],
740) -> String {
741 let mut lines = vec![
742 "Executions".to_string(),
743 "=".repeat(50),
744 format!("Count: {}", records.len()),
745 ];
746
747 for ((record, current_time), process_ids) in records
748 .iter()
749 .zip(current_times.iter())
750 .zip(process_ids.iter())
751 {
752 lines.push(String::new());
753 lines.push(format_record_as_text_with_enrichments(
754 record,
755 current_time.as_deref(),
756 process_ids.as_ref(),
757 ));
758 }
759
760 lines.join("\n")
761}
762
763fn record_list_json_with_current_times(
764 records: &[ExecutionRecord],
765 current_times: &[Option<String>],
766 process_ids: &[Option<Value>],
767) -> Value {
768 let executions: Vec<Value> = records
769 .iter()
770 .zip(current_times.iter())
771 .zip(process_ids.iter())
772 .map(|((record, current_time), process_ids)| {
773 record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
774 })
775 .collect();
776
777 serde_json::json!({
778 "count": records.len(),
779 "executions": executions,
780 })
781}
782
783pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
785 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
786 let process_ids = vec![None; records.len()];
787 format_record_list_with_current_times(records, format, ¤t_times, &process_ids)
788}
789
790fn format_record_list_with_current_times(
791 records: &[ExecutionRecord],
792 format: &str,
793 current_times: &[Option<String>],
794 process_ids: &[Option<Value>],
795) -> Result<String, String> {
796 match format {
797 "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
798 records,
799 current_times,
800 process_ids,
801 )),
802 "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
803 records,
804 current_times,
805 process_ids,
806 ))
807 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
808 "text" => Ok(format_record_list_as_text_with_current_times(
809 records,
810 current_times,
811 process_ids,
812 )),
813 _ => Err(format!("Unknown output format: {}", format)),
814 }
815}
816
817pub struct StatusQueryResult {
819 pub success: bool,
820 pub output: Option<String>,
821 pub error: Option<String>,
822}
823
824pub fn list_executions(
826 store: Option<&ExecutionStore>,
827 output_format: Option<&str>,
828) -> StatusQueryResult {
829 list_executions_filtered(store, output_format, false)
830}
831
832pub fn list_executions_filtered(
838 store: Option<&ExecutionStore>,
839 output_format: Option<&str>,
840 running_only: bool,
841) -> StatusQueryResult {
842 let store = match store {
843 Some(s) => s,
844 None => {
845 return StatusQueryResult {
846 success: false,
847 output: None,
848 error: Some("Execution tracking is disabled.".to_string()),
849 }
850 }
851 };
852
853 let mut records: Vec<ExecutionRecord> =
854 store.get_all().iter().map(enrich_detached_status).collect();
855 if running_only {
856 records.retain(|record| record.status == ExecutionStatus::Executing);
857 }
858 sort_records_by_start_time_desc(&mut records);
859 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
860 let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
861 let format = output_format.unwrap_or("links-notation");
862
863 match format_record_list_with_current_times(&records, format, ¤t_times, &process_ids) {
864 Ok(output) => StatusQueryResult {
865 success: true,
866 output: Some(output),
867 error: None,
868 },
869 Err(e) => StatusQueryResult {
870 success: false,
871 output: None,
872 error: Some(e),
873 },
874 }
875}
876
877pub fn query_status(
879 store: Option<&ExecutionStore>,
880 identifier: &str,
881 output_format: Option<&str>,
882) -> StatusQueryResult {
883 let store = match store {
884 Some(s) => s,
885 None => {
886 return StatusQueryResult {
887 success: false,
888 output: None,
889 error: Some("Execution tracking is disabled.".to_string()),
890 }
891 }
892 };
893
894 let record = match store.get(identifier) {
895 Some(r) => r,
896 None => {
897 return StatusQueryResult {
898 success: false,
899 output: None,
900 error: Some(format!(
901 "No execution found with UUID or session name: {}",
902 identifier
903 )),
904 }
905 }
906 };
907
908 let enriched = enrich_detached_status(&record);
910 let current_time = attach_current_time(&enriched);
912 let process_ids = collect_process_ids(&enriched);
913
914 let format = output_format.unwrap_or("links-notation");
915 match format_record_with_enrichments(
916 &enriched,
917 format,
918 current_time.as_deref(),
919 process_ids.as_ref(),
920 ) {
921 Ok(output) => StatusQueryResult {
922 success: true,
923 output: Some(output),
924 error: None,
925 },
926 Err(e) => StatusQueryResult {
927 success: false,
928 output: None,
929 error: Some(e),
930 },
931 }
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937 use crate::execution_store::ExecutionRecordOptions;
938 use serde_json::json;
939
940 fn executing_record() -> ExecutionRecord {
941 ExecutionRecord::with_options(ExecutionRecordOptions {
942 command: "sleep 60".to_string(),
943 uuid: Some("issue-126-rust".to_string()),
944 pid: Some(667105),
945 status: Some(ExecutionStatus::Executing),
946 log_path: Some("/tmp/issue-126.log".to_string()),
947 start_time: Some("2026-04-23T10:00:00Z".to_string()),
948 working_directory: Some("/home/user".to_string()),
949 shell: Some("/bin/bash".to_string()),
950 platform: Some("linux".to_string()),
951 ..Default::default()
952 })
953 }
954
955 #[test]
956 fn links_notation_indents_nested_process_id_arrays() {
957 let process_ids = json!({
958 "wrapperPid": 667105,
959 "screenPid": 667120,
960 "commandPids": [667121, 667122],
961 });
962 let output = format_record_with_enrichments(
963 &executing_record(),
964 "links-notation",
965 Some("2026-04-23T10:10:13.042Z"),
966 Some(&process_ids),
967 )
968 .expect("links-notation should format");
969
970 assert!(
971 output.contains(
972 " commandPids\n (\n 667121\n 667122\n )"
973 ),
974 "processIds should be a nested indented block, output: {}",
975 output
976 );
977 assert!(
978 !output.contains("\n(\n"),
979 "opening parenthesis must not start at column 1: {}",
980 output
981 );
982 }
983}