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