Skip to main content

start_command/
execution_store.rs

1//! Execution Store - Dual storage (.lino text + .links binary) for command execution records
2
3use crate::lino_value_json::{json_to_lino_value, lino_value_to_json};
4use crate::local_hostname;
5use chrono::Utc;
6use lino_objects_codec::{decode, encode, LinoValue};
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use std::collections::HashMap;
10use std::env;
11use std::fs::{self, OpenOptions};
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::thread;
16use std::time::Duration;
17use uuid::Uuid;
18
19/// Default application folder name
20const DEFAULT_APP_FOLDER_NAME: &str = ".start-command";
21/// Lino database file name
22const LINO_DB_FILE: &str = "executions.lino";
23/// Links database file name
24const LINKS_DB_FILE: &str = "executions.links";
25/// Lock file name
26const LOCK_FILE: &str = "executions.lock";
27/// Lock timeout in milliseconds
28const LOCK_TIMEOUT_MS: u64 = 30000;
29/// Consider lock stale after this many milliseconds
30const LOCK_STALE_MS: u64 = 60000;
31
32/// Execution status
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum ExecutionStatus {
36    Executing,
37    Executed,
38}
39
40impl ExecutionStatus {
41    pub fn as_str(&self) -> &'static str {
42        match self {
43            ExecutionStatus::Executing => "executing",
44            ExecutionStatus::Executed => "executed",
45        }
46    }
47}
48
49impl std::fmt::Display for ExecutionStatus {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        write!(f, "{}", self.as_str())
52    }
53}
54
55/// Command Execution Record
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ExecutionRecord {
59    pub uuid: String,
60    pub pid: Option<u32>,
61    pub status: ExecutionStatus,
62    pub exit_code: Option<i32>,
63    pub command: String,
64    pub log_path: String,
65    pub start_time: String,
66    pub end_time: Option<String>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub oom_killed: Option<bool>,
69    pub working_directory: String,
70    pub shell: String,
71    pub platform: String,
72    #[serde(default)]
73    pub options: HashMap<String, Value>,
74}
75
76impl ExecutionRecord {
77    /// Create a new execution record
78    pub fn new(command: &str) -> Self {
79        let now = Utc::now();
80        ExecutionRecord {
81            uuid: Uuid::new_v4().to_string(),
82            pid: None,
83            status: ExecutionStatus::Executing,
84            exit_code: None,
85            command: command.to_string(),
86            log_path: String::new(),
87            start_time: now.to_rfc3339(),
88            end_time: None,
89            oom_killed: None,
90            working_directory: env::current_dir()
91                .map(|p| p.to_string_lossy().to_string())
92                .unwrap_or_default(),
93            shell: env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
94            platform: std::env::consts::OS.to_string(),
95            options: HashMap::new(),
96        }
97    }
98
99    /// Create a new execution record with options
100    pub fn with_options(options: ExecutionRecordOptions) -> Self {
101        let mut record = Self::new(&options.command);
102        if let Some(uuid) = options.uuid {
103            record.uuid = uuid;
104        }
105        if let Some(pid) = options.pid {
106            record.pid = Some(pid);
107        }
108        if let Some(status) = options.status {
109            record.status = status;
110        }
111        if let Some(exit_code) = options.exit_code {
112            record.exit_code = Some(exit_code);
113        }
114        if let Some(log_path) = options.log_path {
115            record.log_path = log_path;
116        }
117        if let Some(start_time) = options.start_time {
118            record.start_time = start_time;
119        }
120        if let Some(end_time) = options.end_time {
121            record.end_time = Some(end_time);
122        }
123        if let Some(oom_killed) = options.oom_killed {
124            record.oom_killed = Some(oom_killed);
125        }
126        if let Some(working_directory) = options.working_directory {
127            record.working_directory = working_directory;
128        }
129        if let Some(shell) = options.shell {
130            record.shell = shell;
131        }
132        if let Some(platform) = options.platform {
133            record.platform = platform;
134        }
135        if let Some(opts) = options.options {
136            record.options = opts;
137        }
138        record
139    }
140
141    /// Mark execution as completed
142    pub fn complete(&mut self, exit_code: i32) {
143        self.status = ExecutionStatus::Executed;
144        self.exit_code = Some(exit_code);
145        self.end_time = Some(Utc::now().to_rfc3339());
146    }
147
148    /// Convert to JSON Value
149    pub fn to_json(&self) -> Value {
150        serde_json::to_value(self).unwrap_or(Value::Null)
151    }
152
153    /// Create from JSON Value
154    pub fn from_json(value: &Value) -> Option<Self> {
155        serde_json::from_value(value.clone()).ok()
156    }
157}
158
159/// Options for creating an execution record
160#[derive(Debug, Default)]
161pub struct ExecutionRecordOptions {
162    pub uuid: Option<String>,
163    pub command: String,
164    pub pid: Option<u32>,
165    pub status: Option<ExecutionStatus>,
166    pub exit_code: Option<i32>,
167    pub log_path: Option<String>,
168    pub start_time: Option<String>,
169    pub end_time: Option<String>,
170    pub oom_killed: Option<bool>,
171    pub working_directory: Option<String>,
172    pub shell: Option<String>,
173    pub platform: Option<String>,
174    pub options: Option<HashMap<String, Value>>,
175}
176
177/// File-based lock manager
178pub struct LockManager {
179    lock_file_path: PathBuf,
180    lock_acquired: bool,
181}
182
183impl LockManager {
184    /// Create a new lock manager
185    pub fn new(lock_file_path: PathBuf) -> Self {
186        LockManager {
187            lock_file_path,
188            lock_acquired: false,
189        }
190    }
191
192    /// Acquire an exclusive lock
193    pub fn acquire(&mut self, timeout_ms: u64) -> bool {
194        let start = std::time::Instant::now();
195        let timeout = Duration::from_millis(timeout_ms);
196
197        while start.elapsed() < timeout {
198            // Check if existing lock is stale
199            if self.lock_file_path.exists() {
200                if let Some(lock_data) = self.read_lock_file() {
201                    if self.is_lock_stale(&lock_data) {
202                        let _ = fs::remove_file(&self.lock_file_path);
203                    }
204                }
205            }
206
207            // Try to create lock file exclusively
208            match OpenOptions::new()
209                .write(true)
210                .create_new(true)
211                .open(&self.lock_file_path)
212            {
213                Ok(mut file) => {
214                    let lock_data = json!({
215                        "pid": std::process::id(),
216                        "timestamp": std::time::SystemTime::now()
217                            .duration_since(std::time::UNIX_EPOCH)
218                            .map(|d| d.as_millis())
219                            .unwrap_or(0),
220                        "hostname": local_hostname::get()
221                            .map(|h| h.to_string_lossy().to_string())
222                            .unwrap_or_default()
223                    });
224                    let _ = file.write_all(lock_data.to_string().as_bytes());
225                    self.lock_acquired = true;
226                    return true;
227                }
228                Err(_) => {
229                    // Lock file exists, wait and retry
230                    thread::sleep(Duration::from_millis(100));
231                    continue;
232                }
233            }
234        }
235
236        false
237    }
238
239    /// Release the lock
240    pub fn release(&mut self) {
241        if self.lock_acquired {
242            let _ = fs::remove_file(&self.lock_file_path);
243            self.lock_acquired = false;
244        }
245    }
246
247    /// Read lock file data
248    fn read_lock_file(&self) -> Option<Value> {
249        let content = fs::read_to_string(&self.lock_file_path).ok()?;
250        serde_json::from_str(&content).ok()
251    }
252
253    /// Check if lock is stale
254    fn is_lock_stale(&self, lock_data: &Value) -> bool {
255        let timestamp = lock_data.get("timestamp").and_then(|t| t.as_u64());
256
257        // Check if lock is too old
258        if let Some(ts) = timestamp {
259            let now = std::time::SystemTime::now()
260                .duration_since(std::time::UNIX_EPOCH)
261                .map(|d| d.as_millis() as u64)
262                .unwrap_or(0);
263            if now - ts > LOCK_STALE_MS {
264                return true;
265            }
266        } else {
267            return true;
268        }
269
270        // Check if the process that holds the lock is still running (Unix only)
271        #[cfg(unix)]
272        {
273            let pid = lock_data.get("pid").and_then(|p| p.as_u64());
274            if let Some(p) = pid {
275                // Check if process exists using kill(pid, 0)
276                let result = unsafe { libc::kill(p as i32, 0) };
277                if result != 0 {
278                    return true; // Process doesn't exist
279                }
280            }
281        }
282
283        false
284    }
285}
286
287impl Drop for LockManager {
288    fn drop(&mut self) {
289        self.release();
290    }
291}
292
293/// Check if clink is installed
294pub fn is_clink_installed() -> bool {
295    Command::new("clink")
296        .arg("--version")
297        .stdout(Stdio::null())
298        .stderr(Stdio::null())
299        .status()
300        .map(|s| s.success())
301        .unwrap_or(false)
302}
303
304/// Get the default application folder path
305pub fn get_default_app_folder() -> PathBuf {
306    if let Ok(custom) = env::var("START_APP_FOLDER") {
307        return PathBuf::from(custom);
308    }
309
310    let home = env::var("HOME")
311        .or_else(|_| env::var("USERPROFILE"))
312        .unwrap_or_else(|_| ".".to_string());
313    PathBuf::from(home).join(DEFAULT_APP_FOLDER_NAME)
314}
315
316/// ExecutionStore - Main store class for managing execution records
317#[derive(Clone)]
318pub struct ExecutionStore {
319    app_folder: PathBuf,
320    lino_db_path: PathBuf,
321    links_db_path: PathBuf,
322    lock_file_path: PathBuf,
323    use_links: bool,
324    verbose: bool,
325}
326
327/// Options for creating an ExecutionStore
328#[derive(Debug, Default)]
329pub struct ExecutionStoreOptions {
330    pub app_folder: Option<PathBuf>,
331    pub use_links: Option<bool>,
332    pub verbose: bool,
333}
334
335impl ExecutionStore {
336    /// Create a new ExecutionStore with default options
337    pub fn new() -> Self {
338        Self::with_options(ExecutionStoreOptions::default())
339    }
340
341    /// Create a new ExecutionStore with options
342    pub fn with_options(options: ExecutionStoreOptions) -> Self {
343        let app_folder = options.app_folder.unwrap_or_else(get_default_app_folder);
344        let lino_db_path = app_folder.join(LINO_DB_FILE);
345        let links_db_path = app_folder.join(LINKS_DB_FILE);
346        let lock_file_path = app_folder.join(LOCK_FILE);
347        let use_links = options.use_links.unwrap_or_else(is_clink_installed);
348
349        // Ensure app folder exists
350        let _ = fs::create_dir_all(&app_folder);
351
352        ExecutionStore {
353            app_folder,
354            lino_db_path,
355            links_db_path,
356            lock_file_path,
357            use_links,
358            verbose: options.verbose,
359        }
360    }
361
362    /// Log verbose message
363    fn log(&self, message: &str) {
364        if self.verbose {
365            println!("[ExecutionStore] {}", message);
366        }
367    }
368
369    /// Read all execution records from lino file
370    pub fn read_lino_records(&self) -> Vec<ExecutionRecord> {
371        if !self.lino_db_path.exists() {
372            return Vec::new();
373        }
374
375        match fs::read_to_string(&self.lino_db_path) {
376            Ok(content) => {
377                if content.trim().is_empty() {
378                    return Vec::new();
379                }
380
381                match decode(&content) {
382                    Ok(data) => {
383                        if let LinoValue::Array(arr) = data {
384                            arr.iter()
385                                .map(lino_value_to_json)
386                                .filter_map(|value| ExecutionRecord::from_json(&value))
387                                .collect()
388                        } else {
389                            Vec::new()
390                        }
391                    }
392                    Err(e) => {
393                        self.log(&format!("Error decoding lino records: {}", e));
394                        Vec::new()
395                    }
396                }
397            }
398            Err(e) => {
399                self.log(&format!("Error reading lino records: {}", e));
400                Vec::new()
401            }
402        }
403    }
404
405    /// Write execution records to lino file
406    fn write_lino_records(&self, records: &[ExecutionRecord]) -> std::io::Result<()> {
407        let data: Vec<LinoValue> = records
408            .iter()
409            .map(|record| json_to_lino_value(&record.to_json()))
410            .collect();
411        let content = encode(&LinoValue::Array(data));
412        fs::write(&self.lino_db_path, content)?;
413        self.log(&format!("Wrote {} records to lino file", records.len()));
414        Ok(())
415    }
416
417    /// Build clink query for creating/updating a record
418    fn build_clink_create_query(&self, record: &ExecutionRecord) -> String {
419        let obj = record.to_json();
420        let mut links = Vec::new();
421
422        // Create main record link
423        links.push(format!(
424            "({}: ExecutionRecord {})",
425            record.uuid, record.uuid
426        ));
427
428        // Create property links
429        if let Value::Object(map) = obj {
430            for (key, value) in map {
431                let escaped_value = match value {
432                    Value::Object(_) | Value::Array(_) => {
433                        serde_json::to_string(&value).unwrap_or_default()
434                    }
435                    Value::String(s) => s,
436                    Value::Null => "null".to_string(),
437                    other => other.to_string(),
438                };
439                links.push(format!(
440                    "({}.{}: {} \"{}\")",
441                    record.uuid,
442                    key,
443                    key,
444                    escaped_value.replace('"', "\\\"")
445                ));
446            }
447        }
448
449        format!("() (({})))", links.join(") ("))
450    }
451
452    /// Execute clink command
453    fn exec_clink(&self, query: &str) -> Result<String, String> {
454        match Command::new("clink")
455            .arg(query)
456            .arg("--db")
457            .arg(&self.links_db_path)
458            .output()
459        {
460            Ok(output) => {
461                if output.status.success() {
462                    Ok(String::from_utf8_lossy(&output.stdout).to_string())
463                } else {
464                    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
465                    self.log(&format!("Clink error: {}", stderr));
466                    Err(stderr)
467                }
468            }
469            Err(e) => {
470                self.log(&format!("Clink execution error: {}", e));
471                Err(e.to_string())
472            }
473        }
474    }
475
476    /// Write a record to the links database using clink
477    fn write_links_record(&self, record: &ExecutionRecord) -> bool {
478        if !self.use_links {
479            return false;
480        }
481
482        let query = self.build_clink_create_query(record);
483        match self.exec_clink(&query) {
484            Ok(_) => {
485                self.log(&format!("Wrote record {} to links database", record.uuid));
486                true
487            }
488            Err(_) => false,
489        }
490    }
491
492    /// Delete a record from links database
493    fn delete_links_record(&self, uuid: &str) -> bool {
494        if !self.use_links {
495            return false;
496        }
497
498        let query = format!("(($id: {} $any)) ()", uuid);
499        self.exec_clink(&query).is_ok()
500    }
501
502    /// Save an execution record (creates or updates)
503    pub fn save(&self, record: &ExecutionRecord) -> Result<(), String> {
504        let mut lock = LockManager::new(self.lock_file_path.clone());
505
506        if !lock.acquire(LOCK_TIMEOUT_MS) {
507            return Err("Failed to acquire lock for database write".to_string());
508        }
509
510        // Read existing records
511        let mut records = self.read_lino_records();
512
513        // Find existing record index
514        let existing_index = records.iter().position(|r| r.uuid == record.uuid);
515
516        if let Some(idx) = existing_index {
517            // Update existing record
518            records[idx] = record.clone();
519        } else {
520            // Add new record
521            records.push(record.clone());
522        }
523
524        // Write to lino file
525        self.write_lino_records(&records)
526            .map_err(|e| e.to_string())?;
527
528        // Also write to links database if available
529        if self.use_links {
530            self.write_links_record(record);
531        }
532
533        Ok(())
534    }
535
536    /// Get an execution record by UUID or session name (falls back to options.sessionName)
537    pub fn get(&self, identifier: &str) -> Option<ExecutionRecord> {
538        let records = self.read_lino_records();
539        records
540            .iter()
541            .find(|r| r.uuid == identifier)
542            .cloned()
543            .or_else(|| {
544                records.into_iter().find(|r| {
545                    r.options.get("sessionName").and_then(|v| v.as_str()) == Some(identifier)
546                })
547            })
548    }
549
550    /// Get all execution records
551    pub fn get_all(&self) -> Vec<ExecutionRecord> {
552        self.read_lino_records()
553    }
554    /// Get records filtered by status
555    pub fn get_by_status(&self, status: ExecutionStatus) -> Vec<ExecutionRecord> {
556        self.read_lino_records()
557            .into_iter()
558            .filter(|r| r.status == status)
559            .collect()
560    }
561    /// Get currently executing commands
562    pub fn get_executing(&self) -> Vec<ExecutionRecord> {
563        self.get_by_status(ExecutionStatus::Executing)
564    }
565    /// Get recently executed commands (sorted by start_time descending)
566    pub fn get_recent(&self, limit: usize) -> Vec<ExecutionRecord> {
567        let mut records = self.read_lino_records();
568        records.sort_by(|a, b| b.start_time.cmp(&a.start_time));
569        records.truncate(limit);
570        records
571    }
572    /// Clean up stale "executing" records (processes no longer running or aged out)
573    pub fn cleanup_stale(&self, options: CleanupOptions) -> CleanupResult {
574        let max_age_ms = options.max_age_ms.unwrap_or(24 * 60 * 60 * 1000);
575        let dry_run = options.dry_run;
576        let mut result = CleanupResult {
577            cleaned: 0,
578            records: Vec::new(),
579            errors: Vec::new(),
580        };
581
582        let records = self.read_lino_records();
583        let mut stale_records: Vec<ExecutionRecord> = Vec::new();
584
585        for record in records
586            .iter()
587            .filter(|r| r.status == ExecutionStatus::Executing)
588        {
589            let mut is_stale = false;
590
591            // Check if process is still running (Unix only, same platform)
592            #[cfg(unix)]
593            if let Some(pid) = record.pid {
594                if record.platform == std::env::consts::OS
595                    && unsafe { libc::kill(pid as i32, 0) } != 0
596                {
597                    is_stale = true;
598                    self.log(&format!("Stale: {} (PID {} gone)", record.uuid, pid));
599                }
600            }
601
602            // Check age
603            if !is_stale {
604                if let Ok(st) = chrono::DateTime::parse_from_rfc3339(&record.start_time) {
605                    let age_ms =
606                        (chrono::Utc::now() - st.with_timezone(&chrono::Utc)).num_milliseconds();
607                    if age_ms > max_age_ms as i64 {
608                        is_stale = true;
609                        self.log(&format!(
610                            "Stale: {} ({}min old)",
611                            record.uuid,
612                            age_ms / 60000
613                        ));
614                    }
615                }
616            }
617
618            if is_stale {
619                stale_records.push(record.clone());
620            }
621        }
622
623        result.records = stale_records.clone();
624
625        if !dry_run && !stale_records.is_empty() {
626            let mut lock = LockManager::new(self.lock_file_path.clone());
627            if !lock.acquire(LOCK_TIMEOUT_MS) {
628                result.errors.push("Failed to acquire lock".to_string());
629                return result;
630            }
631
632            let mut current = self.read_lino_records();
633            for stale in &stale_records {
634                if let Some(i) = current.iter().position(|r| r.uuid == stale.uuid) {
635                    current[i].status = ExecutionStatus::Executed;
636                    current[i].exit_code = Some(-1);
637                    current[i].end_time = Some(chrono::Utc::now().to_rfc3339());
638                    result.cleaned += 1;
639                }
640            }
641
642            if let Err(e) = self.write_lino_records(&current) {
643                result.errors.push(format!("Cleanup error: {}", e));
644            } else {
645                self.log(&format!("Cleaned {} stale records", result.cleaned));
646            }
647        } else if dry_run {
648            result.cleaned = stale_records.len();
649        }
650
651        result
652    }
653
654    /// Delete an execution record
655    pub fn delete(&self, uuid: &str) -> Result<bool, String> {
656        let mut lock = LockManager::new(self.lock_file_path.clone());
657
658        if !lock.acquire(LOCK_TIMEOUT_MS) {
659            return Err("Failed to acquire lock for database write".to_string());
660        }
661
662        let records = self.read_lino_records();
663        let filtered: Vec<_> = records.iter().filter(|r| r.uuid != uuid).cloned().collect();
664
665        if filtered.len() == records.len() {
666            return Ok(false); // Record not found
667        }
668
669        self.write_lino_records(&filtered)
670            .map_err(|e| e.to_string())?;
671
672        // Also delete from links database
673        if self.use_links {
674            self.delete_links_record(uuid);
675        }
676
677        Ok(true)
678    }
679
680    /// Delete all records
681    pub fn clear(&self) -> Result<(), String> {
682        let mut lock = LockManager::new(self.lock_file_path.clone());
683
684        if !lock.acquire(LOCK_TIMEOUT_MS) {
685            return Err("Failed to acquire lock for database write".to_string());
686        }
687
688        self.write_lino_records(&[]).map_err(|e| e.to_string())?;
689
690        // Clear links database by removing the file
691        if self.use_links && self.links_db_path.exists() {
692            let _ = fs::remove_file(&self.links_db_path);
693        }
694
695        Ok(())
696    }
697
698    /// Verify that both databases have consistent data
699    pub fn verify_consistency(&self) -> ConsistencyResult {
700        let mut result = ConsistencyResult {
701            consistent: true,
702            lino_count: 0,
703            links_count: 0,
704            errors: Vec::new(),
705        };
706
707        // Read lino records
708        let lino_records = self.read_lino_records();
709        result.lino_count = lino_records.len();
710
711        if !self.use_links {
712            result
713                .errors
714                .push("clink not installed - links database not available".to_string());
715            return result;
716        }
717
718        // Query links database for all ExecutionRecord links
719        match self.exec_clink("((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))") {
720            Ok(output) => {
721                // Count unique UUIDs in the output
722                let re = regex::Regex::new(r"ExecutionRecord\s+([a-f0-9-]{36})").unwrap();
723                let uuids: std::collections::HashSet<_> = re
724                    .captures_iter(&output)
725                    .filter_map(|c| c.get(1).map(|m| m.as_str().to_lowercase()))
726                    .collect();
727                result.links_count = uuids.len();
728
729                // Check if counts match
730                if result.lino_count != result.links_count {
731                    result.consistent = false;
732                    result.errors.push(format!(
733                        "Record count mismatch: lino={}, links={}",
734                        result.lino_count, result.links_count
735                    ));
736                }
737
738                // Verify each lino record exists in links
739                for record in &lino_records {
740                    if !uuids.contains(&record.uuid.to_lowercase()) {
741                        result.consistent = false;
742                        result.errors.push(format!(
743                            "Record {} missing from links database",
744                            record.uuid
745                        ));
746                    }
747                }
748            }
749            Err(e) => {
750                result
751                    .errors
752                    .push(format!("Failed to query links database: {}", e));
753                result.consistent = false;
754            }
755        }
756
757        result
758    }
759
760    /// Get database statistics
761    pub fn get_stats(&self) -> ExecutionStats {
762        let records = self.read_lino_records();
763        let executing = records
764            .iter()
765            .filter(|r| r.status == ExecutionStatus::Executing)
766            .count();
767        let executed = records
768            .iter()
769            .filter(|r| r.status == ExecutionStatus::Executed)
770            .count();
771        let successful = records
772            .iter()
773            .filter(|r| r.status == ExecutionStatus::Executed && r.exit_code == Some(0))
774            .count();
775        let failed = records
776            .iter()
777            .filter(|r| {
778                r.status == ExecutionStatus::Executed
779                    && r.exit_code.map(|c| c != 0).unwrap_or(false)
780            })
781            .count();
782
783        ExecutionStats {
784            total: records.len(),
785            executing,
786            executed,
787            successful,
788            failed,
789            clink_available: self.use_links,
790            lino_db_path: self.lino_db_path.to_string_lossy().to_string(),
791            links_db_path: self.links_db_path.to_string_lossy().to_string(),
792        }
793    }
794
795    /// Get the app folder path
796    pub fn app_folder(&self) -> &Path {
797        &self.app_folder
798    }
799}
800
801impl Default for ExecutionStore {
802    fn default() -> Self {
803        Self::new()
804    }
805}
806
807/// Result of consistency verification
808#[derive(Debug)]
809pub struct ConsistencyResult {
810    pub consistent: bool,
811    pub lino_count: usize,
812    pub links_count: usize,
813    pub errors: Vec<String>,
814}
815
816/// Options for cleanup_stale: max_age_ms (default 24h), dry_run
817#[derive(Debug, Default)]
818pub struct CleanupOptions {
819    pub max_age_ms: Option<u64>,
820    pub dry_run: bool,
821}
822
823/// Result of cleanup_stale: cleaned count, records, errors
824#[derive(Debug)]
825pub struct CleanupResult {
826    pub cleaned: usize,
827    pub records: Vec<ExecutionRecord>,
828    pub errors: Vec<String>,
829}
830
831/// Execution statistics
832#[derive(Debug)]
833pub struct ExecutionStats {
834    pub total: usize,
835    pub executing: usize,
836    pub executed: usize,
837    pub successful: usize,
838    pub failed: usize,
839    pub clink_available: bool,
840    pub lino_db_path: String,
841    pub links_db_path: String,
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use tempfile::TempDir;
848
849    fn create_test_store() -> (ExecutionStore, TempDir) {
850        let temp_dir = TempDir::new().unwrap();
851        let store = ExecutionStore::with_options(ExecutionStoreOptions {
852            app_folder: Some(temp_dir.path().to_path_buf()),
853            use_links: Some(false), // Disable links for unit tests
854            verbose: false,
855        });
856        (store, temp_dir)
857    }
858
859    #[test]
860    fn test_execution_record_new() {
861        let record = ExecutionRecord::new("echo hello");
862        assert!(!record.uuid.is_empty());
863        assert_eq!(record.command, "echo hello");
864        assert_eq!(record.status, ExecutionStatus::Executing);
865        assert!(record.exit_code.is_none());
866        assert!(record.end_time.is_none());
867    }
868
869    #[test]
870    fn test_execution_record_complete() {
871        let mut record = ExecutionRecord::new("echo hello");
872        assert_eq!(record.status, ExecutionStatus::Executing);
873        assert!(record.exit_code.is_none());
874
875        record.complete(0);
876
877        assert_eq!(record.status, ExecutionStatus::Executed);
878        assert_eq!(record.exit_code, Some(0));
879        assert!(record.end_time.is_some());
880    }
881
882    #[test]
883    fn test_execution_record_json_roundtrip() {
884        let mut record = ExecutionRecord::new("echo hello");
885        record.pid = Some(12345);
886        record.log_path = "/tmp/test.log".to_string();
887
888        let json = record.to_json();
889        let restored = ExecutionRecord::from_json(&json).unwrap();
890
891        assert_eq!(restored.uuid, record.uuid);
892        assert_eq!(restored.command, "echo hello");
893        assert_eq!(restored.pid, Some(12345));
894    }
895
896    #[test]
897    fn test_store_save_and_get() {
898        let (store, _temp) = create_test_store();
899        let mut record = ExecutionRecord::new("echo hello");
900        record.pid = Some(12345);
901        store.save(&record).unwrap();
902        let retrieved = store.get(&record.uuid).unwrap();
903        assert_eq!(
904            (retrieved.uuid, retrieved.command.as_str(), retrieved.pid),
905            (record.uuid, "echo hello", Some(12345))
906        );
907    }
908
909    #[test]
910    fn test_store_update() {
911        let (store, _temp) = create_test_store();
912        let mut record = ExecutionRecord::new("echo hello");
913        store.save(&record).unwrap();
914        record.complete(0);
915        store.save(&record).unwrap();
916        let r = store.get(&record.uuid).unwrap();
917        assert_eq!(
918            (r.status, r.exit_code),
919            (ExecutionStatus::Executed, Some(0))
920        );
921    }
922
923    #[test]
924    fn test_store_get_all() {
925        let (store, _temp) = create_test_store();
926        for i in 1..=3 {
927            store
928                .save(&ExecutionRecord::new(&format!("e{}", i)))
929                .unwrap();
930        }
931        assert_eq!(store.get_all().len(), 3);
932    }
933
934    #[test]
935    fn test_store_get_by_status() {
936        let (store, _temp) = create_test_store();
937        store.save(&ExecutionRecord::new("1")).unwrap();
938        store.save(&ExecutionRecord::new("2")).unwrap();
939        let mut done = ExecutionRecord::new("3");
940        done.complete(0);
941        store.save(&done).unwrap();
942        assert_eq!(
943            (
944                store.get_executing().len(),
945                store.get_by_status(ExecutionStatus::Executed).len()
946            ),
947            (2, 1)
948        );
949    }
950
951    #[test]
952    fn test_store_delete() {
953        let (store, _temp) = create_test_store();
954        let record = ExecutionRecord::new("echo hello");
955        store.save(&record).unwrap();
956        assert!(store.get(&record.uuid).is_some() && store.delete(&record.uuid).unwrap());
957        assert!(store.get(&record.uuid).is_none());
958    }
959
960    #[test]
961    fn test_store_clear() {
962        let (store, _temp) = create_test_store();
963        store.save(&ExecutionRecord::new("1")).unwrap();
964        store.save(&ExecutionRecord::new("2")).unwrap();
965        assert_eq!(store.get_all().len(), 2);
966        store.clear().unwrap();
967        assert_eq!(store.get_all().len(), 0);
968    }
969
970    #[test]
971    fn test_store_get_stats() {
972        let (store, _temp) = create_test_store();
973        store.save(&ExecutionRecord::new("1")).unwrap();
974        let mut ok = ExecutionRecord::new("2");
975        ok.complete(0);
976        store.save(&ok).unwrap();
977        let mut fail = ExecutionRecord::new("3");
978        fail.complete(1);
979        store.save(&fail).unwrap();
980        let s = store.get_stats();
981        assert_eq!(
982            (s.total, s.executing, s.executed, s.successful, s.failed),
983            (3, 1, 2, 1, 1)
984        );
985    }
986    // Note: Additional tests in tests/cleanup.rs
987}