Skip to main content

start_command/
status_formatter.rs

1//! Status formatter module for execution records
2//!
3//! Provides formatting functions for execution status output in various formats:
4//! - Links Notation (links-notation): Structured link doublet format with nested options
5//! - JSON: Standard JSON output
6//! - Text: Human-readable text format
7
8use 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
15/// Check if a detached isolation session is still running
16/// Returns Some(true) if running, Some(false) if not, None if unable to determine
17pub 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            // For SSH, check if the local wrapper PID is still running
49            #[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
77/// Enrich execution record with live session status for detached executions.
78/// If a record shows "executing" but the detached session has actually ended,
79/// returns an updated copy with status "executed". If it shows "executed" but
80/// the session is still running, returns a copy with status "executing".
81pub 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        // Session still running but record says executed - correct it
91        enriched.status = ExecutionStatus::Executing;
92        enriched.exit_code = None;
93        enriched.end_time = None;
94    } else if !alive && enriched.status == ExecutionStatus::Executing {
95        // Session ended but record says executing - correct it
96        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
108/// Compute a `currentTime` value for a record if its status is `executing`.
109/// Returns `None` for completed records. Wrapping this in a helper makes it
110/// easy to attach the same timestamp to all output formats and to test the
111/// behavior deterministically.
112pub 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
120/// Format execution record as Links Notation (indented style)
121/// Uses nested Links notation for object values (like options) instead of JSON
122///
123/// Output format:
124/// ```text
125/// <uuid>
126///   <key> <value>
127///   options
128///     <nested_key> <nested_value>
129///   ...
130/// ```
131pub fn format_record_as_links_notation(record: &ExecutionRecord) -> String {
132    format_record_as_links_notation_with_current_time(record, None)
133}
134
135/// Same as [`format_record_as_links_notation`] but injects a `currentTime`
136/// field (right after `startTime`) when a value is supplied.
137pub 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                    // Format options as nested Links notation
212                    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                            // For other complex types, use nested format
231                            format_value_for_links_notation(&value)
232                        }
233                    };
234                    lines.push(format!("  {} {}", key, formatted_value));
235                }
236            }
237
238            // Insert processIds right after pid so status output groups process
239            // identity with the wrapper PID already present in older output.
240            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            // Insert currentTime right after startTime for readability
247            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
258/// Format execution record as human-readable text
259pub fn format_record_as_text(record: &ExecutionRecord) -> String {
260    format_record_as_text_with_current_time(record, None)
261}
262
263/// Same as [`format_record_as_text`] but adds a `Current Time:` line right
264/// after `Start Time:` when a value is supplied.
265pub 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    // Format options as nested list instead of JSON
333    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
367/// Format execution record based on format type
368pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
369    format_record_with_current_time(record, format, None)
370}
371
372/// Same as [`format_record`] but the output includes `currentTime` when a
373/// value is supplied. Use this from [`query_status`] so all three formats
374/// stay in sync.
375pub 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
423/// Format execution records as a Links Notation list.
424pub 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, &current_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
462/// Format execution records as human-readable text.
463pub 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, &current_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
516/// Format execution records based on format type.
517pub 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, &current_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
550/// Query result from status lookup
551pub struct StatusQueryResult {
552    pub success: bool,
553    pub output: Option<String>,
554    pub error: Option<String>,
555}
556
557/// Handle execution list query and return the result
558pub 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, &current_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
594/// Handle status query and return the result
595pub 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    // Enrich detached execution status with live session check
626    let enriched = enrich_detached_status(&record);
627    // Attach currentTime so callers can see how long an executing command has been running
628    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}