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::io::{Read, Seek, SeekFrom};
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
176const LOG_TAIL_BYTES: u64 = 16 * 1024;
180
181fn read_log_tail(log_path: &str, bytes: u64) -> Option<String> {
187 let mut file = fs::File::open(log_path).ok()?;
188 let size = file.metadata().ok()?.len();
189 let length = size.min(bytes);
190 file.seek(SeekFrom::Start(size - length)).ok()?;
191 let mut buffer = Vec::with_capacity(length as usize);
192 file.take(length).read_to_end(&mut buffer).ok()?;
193 let tail = String::from_utf8_lossy(&buffer).into_owned();
194 if size <= bytes {
195 return Some(tail);
196 }
197 Some(match tail.find('\n') {
198 Some(index) => tail[index + 1..].to_string(),
199 None => String::new(),
200 })
201}
202
203fn is_footer_separator_line(line: &str) -> bool {
204 line.len() >= 10 && line.chars().all(|c| c == '=')
205}
206
207pub fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
224 let tail = read_log_tail(log_path, LOG_TAIL_BYTES)?;
225 let lines: Vec<&str> = tail
226 .lines()
227 .map(|line| line.trim_end_matches('\r'))
228 .collect();
229 for index in (2..lines.len()).rev() {
230 let value = match lines[index].strip_prefix("Exit Code:") {
231 Some(value) => value,
232 None => continue,
233 };
234 if !lines[index - 1].starts_with("Finished:") || !is_footer_separator_line(lines[index - 2])
235 {
236 continue;
237 }
238 if let Ok(code) = value.trim().parse::<i32>() {
239 return Some(code);
240 }
241 }
242 None
243}
244
245pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
250 let footer_exit = read_exit_code_from_log(&record.log_path);
251 let is_detached_docker = is_detached_docker_record(record);
252 let docker_state = if is_detached_docker {
253 read_docker_state(record)
254 } else {
255 None
256 };
257
258 let oom_killed = resolve_oom_observation(record, docker_state);
260 let clone_record = || {
261 let mut enriched = record.clone();
262 if oom_killed.is_some() {
263 enriched.oom_killed = oom_killed;
264 }
265 enriched
266 };
267
268 let alive = if is_detached_docker {
269 docker_state.map(|state| state.running)
270 } else {
271 is_detached_session_alive(record)
272 };
273
274 let alive = match alive {
275 Some(value) => value,
276 None => {
277 let is_detached =
285 record.options.get("isolationMode").and_then(|v| v.as_str()) == Some("detached");
286 if is_detached && record.status == ExecutionStatus::Executing {
287 if footer_exit.is_some() {
288 let mut enriched = clone_record();
289 enriched.status = ExecutionStatus::Executed;
290 enriched.exit_code = footer_exit;
291 if enriched.end_time.is_none() {
292 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
293 }
294 return enriched;
295 }
296 if oom_killed == Some(true) {
297 let mut enriched = clone_record();
304 enriched.status = ExecutionStatus::Executed;
305 enriched.exit_code = Some(137);
306 if enriched.end_time.is_none() {
307 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
308 }
309 return enriched;
310 }
311 }
312 return clone_record();
313 }
314 };
315
316 let mut enriched = clone_record();
317
318 if alive && enriched.status == ExecutionStatus::Executed {
319 if enriched.exit_code.is_none() && footer_exit.is_none() {
326 enriched.status = ExecutionStatus::Executing;
328 enriched.exit_code = None;
329 enriched.end_time = None;
330 }
331 } else if !alive && enriched.status == ExecutionStatus::Executing {
333 enriched.status = ExecutionStatus::Executed;
341 if enriched.exit_code.is_none() {
342 enriched.exit_code = Some(
343 backend_exit_code(docker_state)
344 .or(footer_exit)
345 .or(if oom_killed == Some(true) {
346 Some(137)
347 } else {
348 None
349 })
350 .unwrap_or(-1),
351 );
352 }
353 if enriched.end_time.is_none() {
354 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
355 }
356 }
357
358 enriched
359}
360
361pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
366 if record.status == ExecutionStatus::Executing {
367 Some(chrono::Utc::now().to_rfc3339())
368 } else {
369 None
370 }
371}
372
373pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
385 format_record_as_links_notation_with_current_time(record, None)
386}
387
388pub fn format_record_as_links_notation_with_current_time(
391 record: &ExecutionRecord,
392 current_time: Option<&str>,
393) -> String {
394 format_record_as_links_notation_with_enrichments(record, current_time, None)
395}
396
397fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
398 let prefix = " ".repeat(indent);
399 if values.is_empty() {
400 lines.push(format!("{}()", prefix));
401 return;
402 }
403
404 lines.push(format!("{}(", prefix));
405 for value in values {
406 match value {
407 Value::Array(nested) => append_links_array(lines, nested, indent + 2),
408 Value::Object(map) => {
409 for (child_key, child_value) in map {
410 if !child_value.is_null() {
411 append_links_value(lines, child_key, child_value, indent + 2);
412 }
413 }
414 }
415 _ => lines.push(format!(
416 "{}{}",
417 " ".repeat(indent + 2),
418 format_value_for_links_notation(value)
419 )),
420 }
421 }
422 lines.push(format!("{})", prefix));
423}
424
425fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
426 let prefix = " ".repeat(indent);
427 match value {
428 Value::Object(map) => {
429 if map.is_empty() {
430 return;
431 }
432 lines.push(format!("{}{}", prefix, key));
433 for (child_key, child_value) in map {
434 if !child_value.is_null() {
435 append_links_value(lines, child_key, child_value, indent + 4);
436 }
437 }
438 }
439 Value::Array(values) => {
440 lines.push(format!("{}{}", prefix, key));
441 append_links_array(lines, values, indent + 2);
442 }
443 _ => lines.push(format!(
444 "{}{} {}",
445 prefix,
446 key,
447 format_value_for_links_notation(value)
448 )),
449 }
450}
451
452fn format_record_as_links_notation_with_enrichments(
453 record: &ExecutionRecord,
454 current_time: Option<&str>,
455 process_ids: Option<&Value>,
456) -> String {
457 let json = record.to_json();
458 let mut lines = vec![record.uuid.clone()];
459
460 if let Value::Object(map) = json {
461 for (key, value) in map {
462 if !value.is_null() {
463 if key == "options" {
464 if let Value::Object(opts) = &value {
466 if !opts.is_empty() {
467 lines.push(" options".to_string());
468 for (opt_key, opt_value) in opts {
469 if !opt_value.is_null() {
470 let formatted = format_value_for_links_notation(opt_value);
471 lines.push(format!(" {} {}", opt_key, formatted));
472 }
473 }
474 }
475 }
476 } else {
477 let formatted_value = match &value {
478 Value::String(s) => escape_for_links_notation(s),
479 Value::Bool(b) => b.to_string(),
480 Value::Number(n) => n.to_string(),
481 Value::Null => "null".to_string(),
482 Value::Object(_) | Value::Array(_) => {
483 format_value_for_links_notation(&value)
485 }
486 };
487 lines.push(format!(" {} {}", key, formatted_value));
488 }
489 }
490
491 if key == "pid" {
494 if let Some(process_ids) = process_ids {
495 append_links_value(&mut lines, "processIds", process_ids, 2);
496 }
497 }
498
499 if key == "startTime" {
501 if let Some(ct) = current_time {
502 lines.push(format!(" currentTime {}", escape_for_links_notation(ct)));
503 }
504 }
505 }
506 }
507
508 lines.join("\n")
509}
510
511pub fn format_record_as_text(record: &ExecutionRecord) -> String {
513 format_record_as_text_with_current_time(record, None)
514}
515
516pub fn format_record_as_text_with_current_time(
519 record: &ExecutionRecord,
520 current_time: Option<&str>,
521) -> String {
522 format_record_as_text_with_enrichments(record, current_time, None)
523}
524
525fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
526 let Value::Object(map) = process_ids else {
527 return;
528 };
529 if map.is_empty() {
530 return;
531 }
532
533 lines.push("Process IDs:".to_string());
534 for (key, value) in map {
535 let value_str = match value {
536 Value::String(s) => s.clone(),
537 Value::Bool(b) => b.to_string(),
538 Value::Number(n) => n.to_string(),
539 Value::Null => "null".to_string(),
540 other => serde_json::to_string(other).unwrap_or_default(),
541 };
542 lines.push(format!(" {}: {}", key, value_str));
543 }
544}
545
546fn format_record_as_text_with_enrichments(
547 record: &ExecutionRecord,
548 current_time: Option<&str>,
549 process_ids: Option<&Value>,
550) -> String {
551 let exit_code_str = record
552 .exit_code
553 .map(|c| c.to_string())
554 .unwrap_or_else(|| "N/A".to_string());
555 let pid_str = record
556 .pid
557 .map(|p| p.to_string())
558 .unwrap_or_else(|| "N/A".to_string());
559 let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
560
561 let mut lines = vec![
562 "Execution Status".to_string(),
563 "=".repeat(50),
564 format!("UUID: {}", record.uuid),
565 format!("Status: {}", record.status),
566 format!("Command: {}", record.command),
567 format!("Exit Code: {}", exit_code_str),
568 ];
569 if let Some(oom_killed) = record.oom_killed {
570 lines.push(format!("OOM Killed: {}", oom_killed));
571 }
572 lines.push(format!("PID: {}", pid_str));
573 if let Some(process_ids) = process_ids {
574 append_text_process_ids(&mut lines, process_ids);
575 }
576 lines.extend([
577 format!("Working Directory: {}", record.working_directory),
578 format!("Shell: {}", record.shell),
579 format!("Platform: {}", record.platform),
580 format!("Start Time: {}", record.start_time),
581 ]);
582 if let Some(ct) = current_time {
583 lines.push(format!("Current Time: {}", ct));
584 }
585 lines.push(format!("End Time: {}", end_time_str));
586 lines.push(format!("Log Path: {}", record.log_path));
587
588 if !record.options.is_empty() {
590 lines.push("Options:".to_string());
591 for (key, value) in &record.options {
592 let value_str = match value {
593 Value::String(s) => s.clone(),
594 Value::Bool(b) => b.to_string(),
595 Value::Number(n) => n.to_string(),
596 Value::Null => "null".to_string(),
597 other => serde_json::to_string(other).unwrap_or_default(),
598 };
599 lines.push(format!(" {}: {}", key, value_str));
600 }
601 }
602
603 lines.join("\n")
604}
605
606fn record_json_with_enrichments(
607 record: &ExecutionRecord,
608 current_time: Option<&str>,
609 process_ids: Option<&Value>,
610) -> Value {
611 let mut json = record.to_json();
612 if let Value::Object(map) = &mut json {
613 if let Some(process_ids) = process_ids {
614 map.insert("processIds".to_string(), process_ids.clone());
615 }
616 if let Some(ct) = current_time {
617 map.insert("currentTime".to_string(), Value::String(ct.to_string()));
618 }
619 }
620 json
621}
622
623pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
625 format_record_with_current_time(record, format, None)
626}
627
628pub fn format_record_with_current_time(
632 record: &ExecutionRecord,
633 format: &str,
634 current_time: Option<&str>,
635) -> Result<String, String> {
636 format_record_with_enrichments(record, format, current_time, None)
637}
638
639fn format_record_with_enrichments(
640 record: &ExecutionRecord,
641 format: &str,
642 current_time: Option<&str>,
643 process_ids: Option<&Value>,
644) -> Result<String, String> {
645 match format {
646 "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
647 record,
648 current_time,
649 process_ids,
650 )),
651 "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
652 record,
653 current_time,
654 process_ids,
655 ))
656 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
657 "text" => Ok(format_record_as_text_with_enrichments(
658 record,
659 current_time,
660 process_ids,
661 )),
662 _ => Err(format!("Unknown output format: {}", format)),
663 }
664}
665
666fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
667 records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
668}
669
670fn indent_block(block: &str, spaces: usize) -> String {
671 let prefix = " ".repeat(spaces);
672 block
673 .lines()
674 .map(|line| format!("{}{}", prefix, line))
675 .collect::<Vec<_>>()
676 .join("\n")
677}
678
679pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
681 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
682 let process_ids = vec![None; records.len()];
683 format_record_list_as_links_notation_with_current_times(records, ¤t_times, &process_ids)
684}
685
686fn format_record_list_as_links_notation_with_current_times(
687 records: &[ExecutionRecord],
688 current_times: &[Option<String>],
689 process_ids: &[Option<Value>],
690) -> String {
691 let mut lines = vec![
692 "executions".to_string(),
693 format!(" count {}", records.len()),
694 ];
695
696 if records.is_empty() {
697 lines.push(" records ()".to_string());
698 return lines.join("\n");
699 }
700
701 lines.push(" records".to_string());
702 for ((record, current_time), process_ids) in records
703 .iter()
704 .zip(current_times.iter())
705 .zip(process_ids.iter())
706 {
707 let block = format_record_as_links_notation_with_enrichments(
708 record,
709 current_time.as_deref(),
710 process_ids.as_ref(),
711 );
712 lines.push(indent_block(&block, 4));
713 }
714
715 lines.join("\n")
716}
717
718pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
720 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
721 let process_ids = vec![None; records.len()];
722 format_record_list_as_text_with_current_times(records, ¤t_times, &process_ids)
723}
724
725fn format_record_list_as_text_with_current_times(
726 records: &[ExecutionRecord],
727 current_times: &[Option<String>],
728 process_ids: &[Option<Value>],
729) -> String {
730 let mut lines = vec![
731 "Executions".to_string(),
732 "=".repeat(50),
733 format!("Count: {}", records.len()),
734 ];
735
736 for ((record, current_time), process_ids) in records
737 .iter()
738 .zip(current_times.iter())
739 .zip(process_ids.iter())
740 {
741 lines.push(String::new());
742 lines.push(format_record_as_text_with_enrichments(
743 record,
744 current_time.as_deref(),
745 process_ids.as_ref(),
746 ));
747 }
748
749 lines.join("\n")
750}
751
752fn record_list_json_with_current_times(
753 records: &[ExecutionRecord],
754 current_times: &[Option<String>],
755 process_ids: &[Option<Value>],
756) -> Value {
757 let executions: Vec<Value> = records
758 .iter()
759 .zip(current_times.iter())
760 .zip(process_ids.iter())
761 .map(|((record, current_time), process_ids)| {
762 record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
763 })
764 .collect();
765
766 serde_json::json!({
767 "count": records.len(),
768 "executions": executions,
769 })
770}
771
772pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
774 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
775 let process_ids = vec![None; records.len()];
776 format_record_list_with_current_times(records, format, ¤t_times, &process_ids)
777}
778
779fn format_record_list_with_current_times(
780 records: &[ExecutionRecord],
781 format: &str,
782 current_times: &[Option<String>],
783 process_ids: &[Option<Value>],
784) -> Result<String, String> {
785 match format {
786 "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
787 records,
788 current_times,
789 process_ids,
790 )),
791 "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
792 records,
793 current_times,
794 process_ids,
795 ))
796 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
797 "text" => Ok(format_record_list_as_text_with_current_times(
798 records,
799 current_times,
800 process_ids,
801 )),
802 _ => Err(format!("Unknown output format: {}", format)),
803 }
804}
805
806pub struct StatusQueryResult {
808 pub success: bool,
809 pub output: Option<String>,
810 pub error: Option<String>,
811}
812
813pub fn list_executions(
815 store: Option<&ExecutionStore>,
816 output_format: Option<&str>,
817) -> StatusQueryResult {
818 let store = match store {
819 Some(s) => s,
820 None => {
821 return StatusQueryResult {
822 success: false,
823 output: None,
824 error: Some("Execution tracking is disabled.".to_string()),
825 }
826 }
827 };
828
829 let mut records: Vec<ExecutionRecord> =
830 store.get_all().iter().map(enrich_detached_status).collect();
831 sort_records_by_start_time_desc(&mut records);
832 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
833 let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
834 let format = output_format.unwrap_or("links-notation");
835
836 match format_record_list_with_current_times(&records, format, ¤t_times, &process_ids) {
837 Ok(output) => StatusQueryResult {
838 success: true,
839 output: Some(output),
840 error: None,
841 },
842 Err(e) => StatusQueryResult {
843 success: false,
844 output: None,
845 error: Some(e),
846 },
847 }
848}
849
850pub fn query_status(
852 store: Option<&ExecutionStore>,
853 identifier: &str,
854 output_format: Option<&str>,
855) -> StatusQueryResult {
856 let store = match store {
857 Some(s) => s,
858 None => {
859 return StatusQueryResult {
860 success: false,
861 output: None,
862 error: Some("Execution tracking is disabled.".to_string()),
863 }
864 }
865 };
866
867 let record = match store.get(identifier) {
868 Some(r) => r,
869 None => {
870 return StatusQueryResult {
871 success: false,
872 output: None,
873 error: Some(format!(
874 "No execution found with UUID or session name: {}",
875 identifier
876 )),
877 }
878 }
879 };
880
881 let enriched = enrich_detached_status(&record);
883 let current_time = attach_current_time(&enriched);
885 let process_ids = collect_process_ids(&enriched);
886
887 let format = output_format.unwrap_or("links-notation");
888 match format_record_with_enrichments(
889 &enriched,
890 format,
891 current_time.as_deref(),
892 process_ids.as_ref(),
893 ) {
894 Ok(output) => StatusQueryResult {
895 success: true,
896 output: Some(output),
897 error: None,
898 },
899 Err(e) => StatusQueryResult {
900 success: false,
901 output: None,
902 error: Some(e),
903 },
904 }
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910 use crate::execution_store::ExecutionRecordOptions;
911 use serde_json::json;
912
913 fn executing_record() -> ExecutionRecord {
914 ExecutionRecord::with_options(ExecutionRecordOptions {
915 command: "sleep 60".to_string(),
916 uuid: Some("issue-126-rust".to_string()),
917 pid: Some(667105),
918 status: Some(ExecutionStatus::Executing),
919 log_path: Some("/tmp/issue-126.log".to_string()),
920 start_time: Some("2026-04-23T10:00:00Z".to_string()),
921 working_directory: Some("/home/user".to_string()),
922 shell: Some("/bin/bash".to_string()),
923 platform: Some("linux".to_string()),
924 ..Default::default()
925 })
926 }
927
928 #[test]
929 fn links_notation_indents_nested_process_id_arrays() {
930 let process_ids = json!({
931 "wrapperPid": 667105,
932 "screenPid": 667120,
933 "commandPids": [667121, 667122],
934 });
935 let output = format_record_with_enrichments(
936 &executing_record(),
937 "links-notation",
938 Some("2026-04-23T10:10:13.042Z"),
939 Some(&process_ids),
940 )
941 .expect("links-notation should format");
942
943 assert!(
944 output.contains(
945 " commandPids\n (\n 667121\n 667122\n )"
946 ),
947 "processIds should be a nested indented block, output: {}",
948 output
949 );
950 assert!(
951 !output.contains("\n(\n"),
952 "opening parenthesis must not start at column 1: {}",
953 output
954 );
955 }
956}