1use 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
19const DEFAULT_APP_FOLDER_NAME: &str = ".start-command";
21const LINO_DB_FILE: &str = "executions.lino";
23const LINKS_DB_FILE: &str = "executions.links";
25const LOCK_FILE: &str = "executions.lock";
27const LOCK_TIMEOUT_MS: u64 = 30000;
29const LOCK_STALE_MS: u64 = 60000;
31
32#[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#[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 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 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 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 pub fn to_json(&self) -> Value {
144 serde_json::to_value(self).unwrap_or(Value::Null)
145 }
146
147 pub fn from_json(value: &Value) -> Option<Self> {
149 serde_json::from_value(value.clone()).ok()
150 }
151}
152
153#[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
170pub struct LockManager {
172 lock_file_path: PathBuf,
173 lock_acquired: bool,
174}
175
176impl LockManager {
177 pub fn new(lock_file_path: PathBuf) -> Self {
179 LockManager {
180 lock_file_path,
181 lock_acquired: false,
182 }
183 }
184
185 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 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 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 thread::sleep(Duration::from_millis(100));
224 continue;
225 }
226 }
227 }
228
229 false
230 }
231
232 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 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 fn is_lock_stale(&self, lock_data: &Value) -> bool {
248 let timestamp = lock_data.get("timestamp").and_then(|t| t.as_u64());
249
250 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 #[cfg(unix)]
265 {
266 let pid = lock_data.get("pid").and_then(|p| p.as_u64());
267 if let Some(p) = pid {
268 let result = unsafe { libc::kill(p as i32, 0) };
270 if result != 0 {
271 return true; }
273 }
274 }
275
276 false
277 }
278}
279
280impl Drop for LockManager {
281 fn drop(&mut self) {
282 self.release();
283 }
284}
285
286pub 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
297pub 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#[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#[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 pub fn new() -> Self {
331 Self::with_options(ExecutionStoreOptions::default())
332 }
333
334 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 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 fn log(&self, message: &str) {
357 if self.verbose {
358 println!("[ExecutionStore] {}", message);
359 }
360 }
361
362 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 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 fn build_clink_create_query(&self, record: &ExecutionRecord) -> String {
412 let obj = record.to_json();
413 let mut links = Vec::new();
414
415 links.push(format!(
417 "({}: ExecutionRecord {})",
418 record.uuid, record.uuid
419 ));
420
421 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 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 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 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 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 let mut records = self.read_lino_records();
505
506 let existing_index = records.iter().position(|r| r.uuid == record.uuid);
508
509 if let Some(idx) = existing_index {
510 records[idx] = record.clone();
512 } else {
513 records.push(record.clone());
515 }
516
517 self.write_lino_records(&records)
519 .map_err(|e| e.to_string())?;
520
521 if self.use_links {
523 self.write_links_record(record);
524 }
525
526 Ok(())
527 }
528
529 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 pub fn get_all(&self) -> Vec<ExecutionRecord> {
545 self.read_lino_records()
546 }
547 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 pub fn get_executing(&self) -> Vec<ExecutionRecord> {
556 self.get_by_status(ExecutionStatus::Executing)
557 }
558 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 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 #[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 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(¤t) {
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 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); }
661
662 self.write_lino_records(&filtered)
663 .map_err(|e| e.to_string())?;
664
665 if self.use_links {
667 self.delete_links_record(uuid);
668 }
669
670 Ok(true)
671 }
672
673 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 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 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 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 match self.exec_clink("((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))") {
713 Ok(output) => {
714 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 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 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 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 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#[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#[derive(Debug, Default)]
811pub struct CleanupOptions {
812 pub max_age_ms: Option<u64>,
813 pub dry_run: bool,
814}
815
816#[derive(Debug)]
818pub struct CleanupResult {
819 pub cleaned: usize,
820 pub records: Vec<ExecutionRecord>,
821 pub errors: Vec<String>,
822}
823
824#[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), 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 }