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