1use crate::execution_control::collect_process_ids;
9use crate::execution_store::{ExecutionRecord, ExecutionStatus, ExecutionStore};
10use crate::output_blocks::{escape_for_links_notation, format_value_for_links_notation};
11use serde_json::Value;
12use std::fs;
13use std::process::Command;
14
15pub fn is_detached_session_alive(record: &ExecutionRecord) -> Option<bool> {
18 let session_name = record.options.get("sessionName")?.as_str()?;
19 let isolation_mode = record.options.get("isolationMode")?.as_str()?;
20 let isolated = record.options.get("isolated")?.as_str()?;
21
22 if isolation_mode != "detached" {
23 return None;
24 }
25
26 match isolated {
27 "screen" => {
28 let output = Command::new("screen").args(["-ls"]).output().ok()?;
29 let stdout = String::from_utf8_lossy(&output.stdout);
30 Some(stdout.contains(session_name))
31 }
32 "tmux" => {
33 let status = Command::new("tmux")
34 .args(["has-session", "-t", session_name])
35 .output()
36 .ok()?;
37 Some(status.status.success())
38 }
39 "docker" => {
40 let output = Command::new("docker")
41 .args(["inspect", "-f", "{{.State.Running}}", session_name])
42 .output()
43 .ok()?;
44 let stdout = String::from_utf8_lossy(&output.stdout);
45 Some(stdout.trim() == "true")
46 }
47 "ssh" => {
48 #[cfg(unix)]
50 {
51 if let Some(pid) = record.pid {
52 let result = unsafe { libc::kill(pid as i32, 0) };
53 Some(result == 0)
54 } else {
55 None
56 }
57 }
58 #[cfg(not(unix))]
59 {
60 let _ = record.pid;
61 None
62 }
63 }
64 _ => None,
65 }
66}
67
68fn read_exit_code_from_log(log_path: &str) -> Option<i32> {
69 let content = fs::read_to_string(log_path).ok()?;
70 content
71 .lines()
72 .rev()
73 .find_map(|line| line.trim().strip_prefix("Exit Code:"))
74 .and_then(|value| value.trim().parse::<i32>().ok())
75}
76
77pub fn enrich_detached_status(record: &ExecutionRecord) -> ExecutionRecord {
82 let alive = match is_detached_session_alive(record) {
83 Some(v) => v,
84 None => return record.clone(),
85 };
86
87 let mut enriched = record.clone();
88
89 if alive && enriched.status == ExecutionStatus::Executed {
90 enriched.status = ExecutionStatus::Executing;
92 enriched.exit_code = None;
93 enriched.end_time = None;
94 } else if !alive && enriched.status == ExecutionStatus::Executing {
95 enriched.status = ExecutionStatus::Executed;
97 if enriched.exit_code.is_none() {
98 enriched.exit_code = Some(read_exit_code_from_log(&enriched.log_path).unwrap_or(-1));
99 }
100 if enriched.end_time.is_none() {
101 enriched.end_time = Some(chrono::Utc::now().to_rfc3339());
102 }
103 }
104
105 enriched
106}
107
108pub fn attach_current_time(record: &ExecutionRecord) -> Option<String> {
113 if record.status == ExecutionStatus::Executing {
114 Some(chrono::Utc::now().to_rfc3339())
115 } else {
116 None
117 }
118}
119
120pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
132 format_record_as_links_notation_with_current_time(record, None)
133}
134
135pub fn format_record_as_links_notation_with_current_time(
138 record: &ExecutionRecord,
139 current_time: Option<&str>,
140) -> String {
141 format_record_as_links_notation_with_enrichments(record, current_time, None)
142}
143
144fn append_links_array(lines: &mut Vec<String>, values: &[Value], indent: usize) {
145 let prefix = " ".repeat(indent);
146 if values.is_empty() {
147 lines.push(format!("{}()", prefix));
148 return;
149 }
150
151 lines.push(format!("{}(", prefix));
152 for value in values {
153 match value {
154 Value::Array(nested) => append_links_array(lines, nested, indent + 2),
155 Value::Object(map) => {
156 for (child_key, child_value) in map {
157 if !child_value.is_null() {
158 append_links_value(lines, child_key, child_value, indent + 2);
159 }
160 }
161 }
162 _ => lines.push(format!(
163 "{}{}",
164 " ".repeat(indent + 2),
165 format_value_for_links_notation(value)
166 )),
167 }
168 }
169 lines.push(format!("{})", prefix));
170}
171
172fn append_links_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
173 let prefix = " ".repeat(indent);
174 match value {
175 Value::Object(map) => {
176 if map.is_empty() {
177 return;
178 }
179 lines.push(format!("{}{}", prefix, key));
180 for (child_key, child_value) in map {
181 if !child_value.is_null() {
182 append_links_value(lines, child_key, child_value, indent + 4);
183 }
184 }
185 }
186 Value::Array(values) => {
187 lines.push(format!("{}{}", prefix, key));
188 append_links_array(lines, values, indent + 2);
189 }
190 _ => lines.push(format!(
191 "{}{} {}",
192 prefix,
193 key,
194 format_value_for_links_notation(value)
195 )),
196 }
197}
198
199fn format_record_as_links_notation_with_enrichments(
200 record: &ExecutionRecord,
201 current_time: Option<&str>,
202 process_ids: Option<&Value>,
203) -> String {
204 let json = record.to_json();
205 let mut lines = vec![record.uuid.clone()];
206
207 if let Value::Object(map) = json {
208 for (key, value) in map {
209 if !value.is_null() {
210 if key == "options" {
211 if let Value::Object(opts) = &value {
213 if !opts.is_empty() {
214 lines.push(" options".to_string());
215 for (opt_key, opt_value) in opts {
216 if !opt_value.is_null() {
217 let formatted = format_value_for_links_notation(opt_value);
218 lines.push(format!(" {} {}", opt_key, formatted));
219 }
220 }
221 }
222 }
223 } else {
224 let formatted_value = match &value {
225 Value::String(s) => escape_for_links_notation(s),
226 Value::Bool(b) => b.to_string(),
227 Value::Number(n) => n.to_string(),
228 Value::Null => "null".to_string(),
229 Value::Object(_) | Value::Array(_) => {
230 format_value_for_links_notation(&value)
232 }
233 };
234 lines.push(format!(" {} {}", key, formatted_value));
235 }
236 }
237
238 if key == "pid" {
241 if let Some(process_ids) = process_ids {
242 append_links_value(&mut lines, "processIds", process_ids, 2);
243 }
244 }
245
246 if key == "startTime" {
248 if let Some(ct) = current_time {
249 lines.push(format!(" currentTime {}", escape_for_links_notation(ct)));
250 }
251 }
252 }
253 }
254
255 lines.join("\n")
256}
257
258pub fn format_record_as_text(record: &ExecutionRecord) -> String {
260 format_record_as_text_with_current_time(record, None)
261}
262
263pub fn format_record_as_text_with_current_time(
266 record: &ExecutionRecord,
267 current_time: Option<&str>,
268) -> String {
269 format_record_as_text_with_enrichments(record, current_time, None)
270}
271
272fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
273 let Value::Object(map) = process_ids else {
274 return;
275 };
276 if map.is_empty() {
277 return;
278 }
279
280 lines.push("Process IDs:".to_string());
281 for (key, value) in map {
282 let value_str = match value {
283 Value::String(s) => s.clone(),
284 Value::Bool(b) => b.to_string(),
285 Value::Number(n) => n.to_string(),
286 Value::Null => "null".to_string(),
287 other => serde_json::to_string(other).unwrap_or_default(),
288 };
289 lines.push(format!(" {}: {}", key, value_str));
290 }
291}
292
293fn format_record_as_text_with_enrichments(
294 record: &ExecutionRecord,
295 current_time: Option<&str>,
296 process_ids: Option<&Value>,
297) -> String {
298 let exit_code_str = record
299 .exit_code
300 .map(|c| c.to_string())
301 .unwrap_or_else(|| "N/A".to_string());
302 let pid_str = record
303 .pid
304 .map(|p| p.to_string())
305 .unwrap_or_else(|| "N/A".to_string());
306 let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
307
308 let mut lines = vec![
309 "Execution Status".to_string(),
310 "=".repeat(50),
311 format!("UUID: {}", record.uuid),
312 format!("Status: {}", record.status),
313 format!("Command: {}", record.command),
314 format!("Exit Code: {}", exit_code_str),
315 format!("PID: {}", pid_str),
316 ];
317 if let Some(process_ids) = process_ids {
318 append_text_process_ids(&mut lines, process_ids);
319 }
320 lines.extend([
321 format!("Working Directory: {}", record.working_directory),
322 format!("Shell: {}", record.shell),
323 format!("Platform: {}", record.platform),
324 format!("Start Time: {}", record.start_time),
325 ]);
326 if let Some(ct) = current_time {
327 lines.push(format!("Current Time: {}", ct));
328 }
329 lines.push(format!("End Time: {}", end_time_str));
330 lines.push(format!("Log Path: {}", record.log_path));
331
332 if !record.options.is_empty() {
334 lines.push("Options:".to_string());
335 for (key, value) in &record.options {
336 let value_str = match value {
337 Value::String(s) => s.clone(),
338 Value::Bool(b) => b.to_string(),
339 Value::Number(n) => n.to_string(),
340 Value::Null => "null".to_string(),
341 other => serde_json::to_string(other).unwrap_or_default(),
342 };
343 lines.push(format!(" {}: {}", key, value_str));
344 }
345 }
346
347 lines.join("\n")
348}
349
350fn record_json_with_enrichments(
351 record: &ExecutionRecord,
352 current_time: Option<&str>,
353 process_ids: Option<&Value>,
354) -> Value {
355 let mut json = record.to_json();
356 if let Value::Object(map) = &mut json {
357 if let Some(process_ids) = process_ids {
358 map.insert("processIds".to_string(), process_ids.clone());
359 }
360 if let Some(ct) = current_time {
361 map.insert("currentTime".to_string(), Value::String(ct.to_string()));
362 }
363 }
364 json
365}
366
367pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
369 format_record_with_current_time(record, format, None)
370}
371
372pub fn format_record_with_current_time(
376 record: &ExecutionRecord,
377 format: &str,
378 current_time: Option<&str>,
379) -> Result<String, String> {
380 format_record_with_enrichments(record, format, current_time, None)
381}
382
383fn format_record_with_enrichments(
384 record: &ExecutionRecord,
385 format: &str,
386 current_time: Option<&str>,
387 process_ids: Option<&Value>,
388) -> Result<String, String> {
389 match format {
390 "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
391 record,
392 current_time,
393 process_ids,
394 )),
395 "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
396 record,
397 current_time,
398 process_ids,
399 ))
400 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
401 "text" => Ok(format_record_as_text_with_enrichments(
402 record,
403 current_time,
404 process_ids,
405 )),
406 _ => Err(format!("Unknown output format: {}", format)),
407 }
408}
409
410fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
411 records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
412}
413
414fn indent_block(block: &str, spaces: usize) -> String {
415 let prefix = " ".repeat(spaces);
416 block
417 .lines()
418 .map(|line| format!("{}{}", prefix, line))
419 .collect::<Vec<_>>()
420 .join("\n")
421}
422
423pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
425 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
426 let process_ids = vec![None; records.len()];
427 format_record_list_as_links_notation_with_current_times(records, ¤t_times, &process_ids)
428}
429
430fn format_record_list_as_links_notation_with_current_times(
431 records: &[ExecutionRecord],
432 current_times: &[Option<String>],
433 process_ids: &[Option<Value>],
434) -> String {
435 let mut lines = vec![
436 "executions".to_string(),
437 format!(" count {}", records.len()),
438 ];
439
440 if records.is_empty() {
441 lines.push(" records ()".to_string());
442 return lines.join("\n");
443 }
444
445 lines.push(" records".to_string());
446 for ((record, current_time), process_ids) in records
447 .iter()
448 .zip(current_times.iter())
449 .zip(process_ids.iter())
450 {
451 let block = format_record_as_links_notation_with_enrichments(
452 record,
453 current_time.as_deref(),
454 process_ids.as_ref(),
455 );
456 lines.push(indent_block(&block, 4));
457 }
458
459 lines.join("\n")
460}
461
462pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
464 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
465 let process_ids = vec![None; records.len()];
466 format_record_list_as_text_with_current_times(records, ¤t_times, &process_ids)
467}
468
469fn format_record_list_as_text_with_current_times(
470 records: &[ExecutionRecord],
471 current_times: &[Option<String>],
472 process_ids: &[Option<Value>],
473) -> String {
474 let mut lines = vec![
475 "Executions".to_string(),
476 "=".repeat(50),
477 format!("Count: {}", records.len()),
478 ];
479
480 for ((record, current_time), process_ids) in records
481 .iter()
482 .zip(current_times.iter())
483 .zip(process_ids.iter())
484 {
485 lines.push(String::new());
486 lines.push(format_record_as_text_with_enrichments(
487 record,
488 current_time.as_deref(),
489 process_ids.as_ref(),
490 ));
491 }
492
493 lines.join("\n")
494}
495
496fn record_list_json_with_current_times(
497 records: &[ExecutionRecord],
498 current_times: &[Option<String>],
499 process_ids: &[Option<Value>],
500) -> Value {
501 let executions: Vec<Value> = records
502 .iter()
503 .zip(current_times.iter())
504 .zip(process_ids.iter())
505 .map(|((record, current_time), process_ids)| {
506 record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
507 })
508 .collect();
509
510 serde_json::json!({
511 "count": records.len(),
512 "executions": executions,
513 })
514}
515
516pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
518 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
519 let process_ids = vec![None; records.len()];
520 format_record_list_with_current_times(records, format, ¤t_times, &process_ids)
521}
522
523fn format_record_list_with_current_times(
524 records: &[ExecutionRecord],
525 format: &str,
526 current_times: &[Option<String>],
527 process_ids: &[Option<Value>],
528) -> Result<String, String> {
529 match format {
530 "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
531 records,
532 current_times,
533 process_ids,
534 )),
535 "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
536 records,
537 current_times,
538 process_ids,
539 ))
540 .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
541 "text" => Ok(format_record_list_as_text_with_current_times(
542 records,
543 current_times,
544 process_ids,
545 )),
546 _ => Err(format!("Unknown output format: {}", format)),
547 }
548}
549
550pub struct StatusQueryResult {
552 pub success: bool,
553 pub output: Option<String>,
554 pub error: Option<String>,
555}
556
557pub fn list_executions(
559 store: Option<&ExecutionStore>,
560 output_format: Option<&str>,
561) -> StatusQueryResult {
562 let store = match store {
563 Some(s) => s,
564 None => {
565 return StatusQueryResult {
566 success: false,
567 output: None,
568 error: Some("Execution tracking is disabled.".to_string()),
569 }
570 }
571 };
572
573 let mut records: Vec<ExecutionRecord> =
574 store.get_all().iter().map(enrich_detached_status).collect();
575 sort_records_by_start_time_desc(&mut records);
576 let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
577 let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
578 let format = output_format.unwrap_or("links-notation");
579
580 match format_record_list_with_current_times(&records, format, ¤t_times, &process_ids) {
581 Ok(output) => StatusQueryResult {
582 success: true,
583 output: Some(output),
584 error: None,
585 },
586 Err(e) => StatusQueryResult {
587 success: false,
588 output: None,
589 error: Some(e),
590 },
591 }
592}
593
594pub fn query_status(
596 store: Option<&ExecutionStore>,
597 identifier: &str,
598 output_format: Option<&str>,
599) -> StatusQueryResult {
600 let store = match store {
601 Some(s) => s,
602 None => {
603 return StatusQueryResult {
604 success: false,
605 output: None,
606 error: Some("Execution tracking is disabled.".to_string()),
607 }
608 }
609 };
610
611 let record = match store.get(identifier) {
612 Some(r) => r,
613 None => {
614 return StatusQueryResult {
615 success: false,
616 output: None,
617 error: Some(format!(
618 "No execution found with UUID or session name: {}",
619 identifier
620 )),
621 }
622 }
623 };
624
625 let enriched = enrich_detached_status(&record);
627 let current_time = attach_current_time(&enriched);
629 let process_ids = collect_process_ids(&enriched);
630
631 let format = output_format.unwrap_or("links-notation");
632 match format_record_with_enrichments(
633 &enriched,
634 format,
635 current_time.as_deref(),
636 process_ids.as_ref(),
637 ) {
638 Ok(output) => StatusQueryResult {
639 success: true,
640 output: Some(output),
641 error: None,
642 },
643 Err(e) => StatusQueryResult {
644 success: false,
645 output: None,
646 error: Some(e),
647 },
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use super::*;
654 use crate::execution_store::ExecutionRecordOptions;
655 use serde_json::json;
656
657 fn executing_record() -> ExecutionRecord {
658 ExecutionRecord::with_options(ExecutionRecordOptions {
659 command: "sleep 60".to_string(),
660 uuid: Some("issue-126-rust".to_string()),
661 pid: Some(667105),
662 status: Some(ExecutionStatus::Executing),
663 log_path: Some("/tmp/issue-126.log".to_string()),
664 start_time: Some("2026-04-23T10:00:00Z".to_string()),
665 working_directory: Some("/home/user".to_string()),
666 shell: Some("/bin/bash".to_string()),
667 platform: Some("linux".to_string()),
668 ..Default::default()
669 })
670 }
671
672 #[test]
673 fn links_notation_indents_nested_process_id_arrays() {
674 let process_ids = json!({
675 "wrapperPid": 667105,
676 "screenPid": 667120,
677 "commandPids": [667121, 667122],
678 });
679 let output = format_record_with_enrichments(
680 &executing_record(),
681 "links-notation",
682 Some("2026-04-23T10:10:13.042Z"),
683 Some(&process_ids),
684 )
685 .expect("links-notation should format");
686
687 assert!(
688 output.contains(
689 " commandPids\n (\n 667121\n 667122\n )"
690 ),
691 "processIds should be a nested indented block, output: {}",
692 output
693 );
694 assert!(
695 !output.contains("\n(\n"),
696 "opening parenthesis must not start at column 1: {}",
697 output
698 );
699 }
700}