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 #[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 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 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 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 pub fn to_json(&self) -> Value {
150 serde_json::to_value(self).unwrap_or(Value::Null)
151 }
152
153 pub fn from_json(value: &Value) -> Option<Self> {
155 serde_json::from_value(value.clone()).ok()
156 }
157}
158
159#[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
177pub struct LockManager {
179 lock_file_path: PathBuf,
180 lock_acquired: bool,
181}
182
183impl LockManager {
184 pub fn new(lock_file_path: PathBuf) -> Self {
186 LockManager {
187 lock_file_path,
188 lock_acquired: false,
189 }
190 }
191
192 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 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 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 thread::sleep(Duration::from_millis(100));
231 continue;
232 }
233 }
234 }
235
236 false
237 }
238
239 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 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 fn is_lock_stale(&self, lock_data: &Value) -> bool {
255 let timestamp = lock_data.get("timestamp").and_then(|t| t.as_u64());
256
257 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 #[cfg(unix)]
272 {
273 let pid = lock_data.get("pid").and_then(|p| p.as_u64());
274 if let Some(p) = pid {
275 let result = unsafe { libc::kill(p as i32, 0) };
277 if result != 0 {
278 return true; }
280 }
281 }
282
283 false
284 }
285}
286
287impl Drop for LockManager {
288 fn drop(&mut self) {
289 self.release();
290 }
291}
292
293pub 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
304pub 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#[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#[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 pub fn new() -> Self {
338 Self::with_options(ExecutionStoreOptions::default())
339 }
340
341 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 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 fn log(&self, message: &str) {
364 if self.verbose {
365 println!("[ExecutionStore] {}", message);
366 }
367 }
368
369 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 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 fn build_clink_create_query(&self, record: &ExecutionRecord) -> String {
419 let obj = record.to_json();
420 let mut links = Vec::new();
421
422 links.push(format!(
424 "({}: ExecutionRecord {})",
425 record.uuid, record.uuid
426 ));
427
428 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 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 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 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 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 let mut records = self.read_lino_records();
512
513 let existing_index = records.iter().position(|r| r.uuid == record.uuid);
515
516 if let Some(idx) = existing_index {
517 records[idx] = record.clone();
519 } else {
520 records.push(record.clone());
522 }
523
524 self.write_lino_records(&records)
526 .map_err(|e| e.to_string())?;
527
528 if self.use_links {
530 self.write_links_record(record);
531 }
532
533 Ok(())
534 }
535
536 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 pub fn get_all(&self) -> Vec<ExecutionRecord> {
552 self.read_lino_records()
553 }
554 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 pub fn get_executing(&self) -> Vec<ExecutionRecord> {
563 self.get_by_status(ExecutionStatus::Executing)
564 }
565 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 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 #[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 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(¤t) {
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 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); }
668
669 self.write_lino_records(&filtered)
670 .map_err(|e| e.to_string())?;
671
672 if self.use_links {
674 self.delete_links_record(uuid);
675 }
676
677 Ok(true)
678 }
679
680 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 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 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 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 match self.exec_clink("((($id: ExecutionRecord $uuid)) (($id: ExecutionRecord $uuid)))") {
720 Ok(output) => {
721 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 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 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 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 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#[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#[derive(Debug, Default)]
818pub struct CleanupOptions {
819 pub max_age_ms: Option<u64>,
820 pub dry_run: bool,
821}
822
823#[derive(Debug)]
825pub struct CleanupResult {
826 pub cleaned: usize,
827 pub records: Vec<ExecutionRecord>,
828 pub errors: Vec<String>,
829}
830
831#[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), 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 }