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