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_value(lines: &mut Vec<String>, key: &str, value: &Value, indent: usize) {
145    let prefix = " ".repeat(indent);
146    match value {
147        Value::Object(map) => {
148            if map.is_empty() {
149                return;
150            }
151            lines.push(format!("{}{}", prefix, key));
152            for (child_key, child_value) in map {
153                if !child_value.is_null() {
154                    append_links_value(lines, child_key, child_value, indent + 2);
155                }
156            }
157        }
158        _ => lines.push(format!(
159            "{}{} {}",
160            prefix,
161            key,
162            format_value_for_links_notation(value)
163        )),
164    }
165}
166
167fn format_record_as_links_notation_with_enrichments(
168    record: &ExecutionRecord,
169    current_time: Option<&str>,
170    process_ids: Option<&Value>,
171) -> String {
172    let json = record.to_json();
173    let mut lines = vec![record.uuid.clone()];
174
175    if let Value::Object(map) = json {
176        for (key, value) in map {
177            if !value.is_null() {
178                if key == "options" {
179                    // Format options as nested Links notation
180                    if let Value::Object(opts) = &value {
181                        if !opts.is_empty() {
182                            lines.push("  options".to_string());
183                            for (opt_key, opt_value) in opts {
184                                if !opt_value.is_null() {
185                                    let formatted = format_value_for_links_notation(opt_value);
186                                    lines.push(format!("    {} {}", opt_key, formatted));
187                                }
188                            }
189                        }
190                    }
191                } else {
192                    let formatted_value = match &value {
193                        Value::String(s) => escape_for_links_notation(s),
194                        Value::Bool(b) => b.to_string(),
195                        Value::Number(n) => n.to_string(),
196                        Value::Null => "null".to_string(),
197                        Value::Object(_) | Value::Array(_) => {
198                            // For other complex types, use nested format
199                            format_value_for_links_notation(&value)
200                        }
201                    };
202                    lines.push(format!("  {} {}", key, formatted_value));
203                }
204            }
205
206            // Insert processIds right after pid so status output groups process
207            // identity with the wrapper PID already present in older output.
208            if key == "pid" {
209                if let Some(process_ids) = process_ids {
210                    append_links_value(&mut lines, "processIds", process_ids, 2);
211                }
212            }
213
214            // Insert currentTime right after startTime for readability
215            if key == "startTime" {
216                if let Some(ct) = current_time {
217                    lines.push(format!("  currentTime {}", escape_for_links_notation(ct)));
218                }
219            }
220        }
221    }
222
223    lines.join("\n")
224}
225
226/// Format execution record as human-readable text
227pub fn format_record_as_text(record: &ExecutionRecord) -> String {
228    format_record_as_text_with_current_time(record, None)
229}
230
231/// Same as [`format_record_as_text`] but adds a `Current Time:` line right
232/// after `Start Time:` when a value is supplied.
233pub fn format_record_as_text_with_current_time(
234    record: &ExecutionRecord,
235    current_time: Option<&str>,
236) -> String {
237    format_record_as_text_with_enrichments(record, current_time, None)
238}
239
240fn append_text_process_ids(lines: &mut Vec<String>, process_ids: &Value) {
241    let Value::Object(map) = process_ids else {
242        return;
243    };
244    if map.is_empty() {
245        return;
246    }
247
248    lines.push("Process IDs:".to_string());
249    for (key, value) in map {
250        let value_str = match value {
251            Value::String(s) => s.clone(),
252            Value::Bool(b) => b.to_string(),
253            Value::Number(n) => n.to_string(),
254            Value::Null => "null".to_string(),
255            other => serde_json::to_string(other).unwrap_or_default(),
256        };
257        lines.push(format!("  {}: {}", key, value_str));
258    }
259}
260
261fn format_record_as_text_with_enrichments(
262    record: &ExecutionRecord,
263    current_time: Option<&str>,
264    process_ids: Option<&Value>,
265) -> String {
266    let exit_code_str = record
267        .exit_code
268        .map(|c| c.to_string())
269        .unwrap_or_else(|| "N/A".to_string());
270    let pid_str = record
271        .pid
272        .map(|p| p.to_string())
273        .unwrap_or_else(|| "N/A".to_string());
274    let end_time_str = record.end_time.as_deref().unwrap_or("N/A");
275
276    let mut lines = vec![
277        "Execution Status".to_string(),
278        "=".repeat(50),
279        format!("UUID:              {}", record.uuid),
280        format!("Status:            {}", record.status),
281        format!("Command:           {}", record.command),
282        format!("Exit Code:         {}", exit_code_str),
283        format!("PID:               {}", pid_str),
284    ];
285    if let Some(process_ids) = process_ids {
286        append_text_process_ids(&mut lines, process_ids);
287    }
288    lines.extend([
289        format!("Working Directory: {}", record.working_directory),
290        format!("Shell:             {}", record.shell),
291        format!("Platform:          {}", record.platform),
292        format!("Start Time:        {}", record.start_time),
293    ]);
294    if let Some(ct) = current_time {
295        lines.push(format!("Current Time:      {}", ct));
296    }
297    lines.push(format!("End Time:          {}", end_time_str));
298    lines.push(format!("Log Path:          {}", record.log_path));
299
300    // Format options as nested list instead of JSON
301    if !record.options.is_empty() {
302        lines.push("Options:".to_string());
303        for (key, value) in &record.options {
304            let value_str = match value {
305                Value::String(s) => s.clone(),
306                Value::Bool(b) => b.to_string(),
307                Value::Number(n) => n.to_string(),
308                Value::Null => "null".to_string(),
309                other => serde_json::to_string(other).unwrap_or_default(),
310            };
311            lines.push(format!("  {}: {}", key, value_str));
312        }
313    }
314
315    lines.join("\n")
316}
317
318fn record_json_with_enrichments(
319    record: &ExecutionRecord,
320    current_time: Option<&str>,
321    process_ids: Option<&Value>,
322) -> Value {
323    let mut json = record.to_json();
324    if let Value::Object(map) = &mut json {
325        if let Some(process_ids) = process_ids {
326            map.insert("processIds".to_string(), process_ids.clone());
327        }
328        if let Some(ct) = current_time {
329            map.insert("currentTime".to_string(), Value::String(ct.to_string()));
330        }
331    }
332    json
333}
334
335/// Format execution record based on format type
336pub fn format_record(record: &ExecutionRecord, format: &str) -> Result<String, String> {
337    format_record_with_current_time(record, format, None)
338}
339
340/// Same as [`format_record`] but the output includes `currentTime` when a
341/// value is supplied. Use this from [`query_status`] so all three formats
342/// stay in sync.
343pub fn format_record_with_current_time(
344    record: &ExecutionRecord,
345    format: &str,
346    current_time: Option<&str>,
347) -> Result<String, String> {
348    format_record_with_enrichments(record, format, current_time, None)
349}
350
351fn format_record_with_enrichments(
352    record: &ExecutionRecord,
353    format: &str,
354    current_time: Option<&str>,
355    process_ids: Option<&Value>,
356) -> Result<String, String> {
357    match format {
358        "links-notation" => Ok(format_record_as_links_notation_with_enrichments(
359            record,
360            current_time,
361            process_ids,
362        )),
363        "json" => serde_json::to_string_pretty(&record_json_with_enrichments(
364            record,
365            current_time,
366            process_ids,
367        ))
368        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
369        "text" => Ok(format_record_as_text_with_enrichments(
370            record,
371            current_time,
372            process_ids,
373        )),
374        _ => Err(format!("Unknown output format: {}", format)),
375    }
376}
377
378fn sort_records_by_start_time_desc(records: &mut [ExecutionRecord]) {
379    records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
380}
381
382fn indent_block(block: &str, spaces: usize) -> String {
383    let prefix = " ".repeat(spaces);
384    block
385        .lines()
386        .map(|line| format!("{}{}", prefix, line))
387        .collect::<Vec<_>>()
388        .join("\n")
389}
390
391/// Format execution records as a Links Notation list.
392pub fn format_record_list_as_links_notation(records: &[ExecutionRecord]) -> String {
393    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
394    let process_ids = vec![None; records.len()];
395    format_record_list_as_links_notation_with_current_times(records, &current_times, &process_ids)
396}
397
398fn format_record_list_as_links_notation_with_current_times(
399    records: &[ExecutionRecord],
400    current_times: &[Option<String>],
401    process_ids: &[Option<Value>],
402) -> String {
403    let mut lines = vec![
404        "executions".to_string(),
405        format!("  count {}", records.len()),
406    ];
407
408    if records.is_empty() {
409        lines.push("  records ()".to_string());
410        return lines.join("\n");
411    }
412
413    lines.push("  records".to_string());
414    for ((record, current_time), process_ids) in records
415        .iter()
416        .zip(current_times.iter())
417        .zip(process_ids.iter())
418    {
419        let block = format_record_as_links_notation_with_enrichments(
420            record,
421            current_time.as_deref(),
422            process_ids.as_ref(),
423        );
424        lines.push(indent_block(&block, 4));
425    }
426
427    lines.join("\n")
428}
429
430/// Format execution records as human-readable text.
431pub fn format_record_list_as_text(records: &[ExecutionRecord]) -> String {
432    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
433    let process_ids = vec![None; records.len()];
434    format_record_list_as_text_with_current_times(records, &current_times, &process_ids)
435}
436
437fn format_record_list_as_text_with_current_times(
438    records: &[ExecutionRecord],
439    current_times: &[Option<String>],
440    process_ids: &[Option<Value>],
441) -> String {
442    let mut lines = vec![
443        "Executions".to_string(),
444        "=".repeat(50),
445        format!("Count: {}", records.len()),
446    ];
447
448    for ((record, current_time), process_ids) in records
449        .iter()
450        .zip(current_times.iter())
451        .zip(process_ids.iter())
452    {
453        lines.push(String::new());
454        lines.push(format_record_as_text_with_enrichments(
455            record,
456            current_time.as_deref(),
457            process_ids.as_ref(),
458        ));
459    }
460
461    lines.join("\n")
462}
463
464fn record_list_json_with_current_times(
465    records: &[ExecutionRecord],
466    current_times: &[Option<String>],
467    process_ids: &[Option<Value>],
468) -> Value {
469    let executions: Vec<Value> = records
470        .iter()
471        .zip(current_times.iter())
472        .zip(process_ids.iter())
473        .map(|((record, current_time), process_ids)| {
474            record_json_with_enrichments(record, current_time.as_deref(), process_ids.as_ref())
475        })
476        .collect();
477
478    serde_json::json!({
479        "count": records.len(),
480        "executions": executions,
481    })
482}
483
484/// Format execution records based on format type.
485pub fn format_record_list(records: &[ExecutionRecord], format: &str) -> Result<String, String> {
486    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
487    let process_ids = vec![None; records.len()];
488    format_record_list_with_current_times(records, format, &current_times, &process_ids)
489}
490
491fn format_record_list_with_current_times(
492    records: &[ExecutionRecord],
493    format: &str,
494    current_times: &[Option<String>],
495    process_ids: &[Option<Value>],
496) -> Result<String, String> {
497    match format {
498        "links-notation" => Ok(format_record_list_as_links_notation_with_current_times(
499            records,
500            current_times,
501            process_ids,
502        )),
503        "json" => serde_json::to_string_pretty(&record_list_json_with_current_times(
504            records,
505            current_times,
506            process_ids,
507        ))
508        .map_err(|e| format!("Failed to serialize to JSON: {}", e)),
509        "text" => Ok(format_record_list_as_text_with_current_times(
510            records,
511            current_times,
512            process_ids,
513        )),
514        _ => Err(format!("Unknown output format: {}", format)),
515    }
516}
517
518/// Query result from status lookup
519pub struct StatusQueryResult {
520    pub success: bool,
521    pub output: Option<String>,
522    pub error: Option<String>,
523}
524
525/// Handle execution list query and return the result
526pub fn list_executions(
527    store: Option<&ExecutionStore>,
528    output_format: Option<&str>,
529) -> StatusQueryResult {
530    let store = match store {
531        Some(s) => s,
532        None => {
533            return StatusQueryResult {
534                success: false,
535                output: None,
536                error: Some("Execution tracking is disabled.".to_string()),
537            }
538        }
539    };
540
541    let mut records: Vec<ExecutionRecord> =
542        store.get_all().iter().map(enrich_detached_status).collect();
543    sort_records_by_start_time_desc(&mut records);
544    let current_times: Vec<Option<String>> = records.iter().map(attach_current_time).collect();
545    let process_ids: Vec<Option<Value>> = records.iter().map(collect_process_ids).collect();
546    let format = output_format.unwrap_or("links-notation");
547
548    match format_record_list_with_current_times(&records, format, &current_times, &process_ids) {
549        Ok(output) => StatusQueryResult {
550            success: true,
551            output: Some(output),
552            error: None,
553        },
554        Err(e) => StatusQueryResult {
555            success: false,
556            output: None,
557            error: Some(e),
558        },
559    }
560}
561
562/// Handle status query and return the result
563pub fn query_status(
564    store: Option<&ExecutionStore>,
565    identifier: &str,
566    output_format: Option<&str>,
567) -> StatusQueryResult {
568    let store = match store {
569        Some(s) => s,
570        None => {
571            return StatusQueryResult {
572                success: false,
573                output: None,
574                error: Some("Execution tracking is disabled.".to_string()),
575            }
576        }
577    };
578
579    let record = match store.get(identifier) {
580        Some(r) => r,
581        None => {
582            return StatusQueryResult {
583                success: false,
584                output: None,
585                error: Some(format!(
586                    "No execution found with UUID or session name: {}",
587                    identifier
588                )),
589            }
590        }
591    };
592
593    // Enrich detached execution status with live session check
594    let enriched = enrich_detached_status(&record);
595    // Attach currentTime so callers can see how long an executing command has been running
596    let current_time = attach_current_time(&enriched);
597    let process_ids = collect_process_ids(&enriched);
598
599    let format = output_format.unwrap_or("links-notation");
600    match format_record_with_enrichments(
601        &enriched,
602        format,
603        current_time.as_deref(),
604        process_ids.as_ref(),
605    ) {
606        Ok(output) => StatusQueryResult {
607            success: true,
608            output: Some(output),
609            error: None,
610        },
611        Err(e) => StatusQueryResult {
612            success: false,
613            output: None,
614            error: Some(e),
615        },
616    }
617}