Skip to main content

bashrs/installer/
checkpoint.rs

1//! Checkpoint System for Installer Framework (#106)
2//!
3//! Provides SQLite-based checkpoint storage for resumable installations.
4//!
5//! # Features
6//!
7//! - Resume from any failure point
8//! - Track step status and state snapshots
9//! - Store file state for rollback
10//! - Verify hermetic mode consistency
11//!
12//! # Storage Schema
13//!
14//! - `installer_runs` - Overall run metadata
15//! - `step_checkpoints` - Per-step status and state
16//! - `state_files` - File state tracking for rollback
17
18use crate::models::{Error, Result};
19use std::path::{Path, PathBuf};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22/// Status of an installer run
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum RunStatus {
25    Running,
26    Completed,
27    Failed,
28    Aborted,
29}
30
31impl RunStatus {
32    pub fn as_str(&self) -> &'static str {
33        match self {
34            Self::Running => "running",
35            Self::Completed => "completed",
36            Self::Failed => "failed",
37            Self::Aborted => "aborted",
38        }
39    }
40
41    /// Parse from string
42    pub fn parse(s: &str) -> Option<Self> {
43        match s {
44            "running" => Some(Self::Running),
45            "completed" => Some(Self::Completed),
46            "failed" => Some(Self::Failed),
47            "aborted" => Some(Self::Aborted),
48            _ => None,
49        }
50    }
51}
52
53/// Status of a step checkpoint
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum StepStatus {
56    Pending,
57    Running,
58    Completed,
59    Failed,
60    Skipped,
61}
62
63impl StepStatus {
64    pub fn as_str(&self) -> &'static str {
65        match self {
66            Self::Pending => "pending",
67            Self::Running => "running",
68            Self::Completed => "completed",
69            Self::Failed => "failed",
70            Self::Skipped => "skipped",
71        }
72    }
73
74    /// Parse from string
75    pub fn parse(s: &str) -> Option<Self> {
76        match s {
77            "pending" => Some(Self::Pending),
78            "running" => Some(Self::Running),
79            "completed" => Some(Self::Completed),
80            "failed" => Some(Self::Failed),
81            "skipped" => Some(Self::Skipped),
82            _ => None,
83        }
84    }
85}
86
87/// Metadata for an installer run
88#[derive(Debug, Clone)]
89pub struct InstallerRun {
90    /// Unique run identifier
91    pub run_id: String,
92    /// Name of the installer
93    pub installer_name: String,
94    /// Version of the installer
95    pub installer_version: String,
96    /// When the run started
97    pub started_at: u64,
98    /// When the run completed (if finished)
99    pub completed_at: Option<u64>,
100    /// Current status
101    pub status: RunStatus,
102    /// Whether running in hermetic mode
103    pub hermetic_mode: bool,
104    /// Lockfile hash (for hermetic mode)
105    pub lockfile_hash: Option<String>,
106}
107
108impl InstallerRun {
109    /// Create a new installer run
110    pub fn new(installer_name: &str, installer_version: &str) -> Self {
111        let run_id = generate_run_id();
112        let started_at = current_timestamp();
113
114        Self {
115            run_id,
116            installer_name: installer_name.to_string(),
117            installer_version: installer_version.to_string(),
118            started_at,
119            completed_at: None,
120            status: RunStatus::Running,
121            hermetic_mode: false,
122            lockfile_hash: None,
123        }
124    }
125
126    /// Create a new hermetic installer run
127    pub fn new_hermetic(
128        installer_name: &str,
129        installer_version: &str,
130        lockfile_hash: &str,
131    ) -> Self {
132        let mut run = Self::new(installer_name, installer_version);
133        run.hermetic_mode = true;
134        run.lockfile_hash = Some(lockfile_hash.to_string());
135        run
136    }
137
138    /// Mark the run as completed
139    pub fn complete(&mut self) {
140        self.status = RunStatus::Completed;
141        self.completed_at = Some(current_timestamp());
142    }
143
144    /// Mark the run as failed
145    pub fn fail(&mut self) {
146        self.status = RunStatus::Failed;
147        self.completed_at = Some(current_timestamp());
148    }
149}
150
151/// Checkpoint for a single step
152#[derive(Debug, Clone)]
153pub struct StepCheckpoint {
154    /// Run this checkpoint belongs to
155    pub run_id: String,
156    /// Step identifier
157    pub step_id: String,
158    /// Current status
159    pub status: StepStatus,
160    /// When the step started
161    pub started_at: Option<u64>,
162    /// When the step completed
163    pub completed_at: Option<u64>,
164    /// Duration in milliseconds
165    pub duration_ms: Option<u64>,
166    /// State snapshot as JSON
167    pub state_snapshot: Option<String>,
168    /// Output log
169    pub output_log: Option<String>,
170    /// Error message if failed
171    pub error_message: Option<String>,
172}
173
174impl StepCheckpoint {
175    /// Create a new pending step checkpoint
176    pub fn new(run_id: &str, step_id: &str) -> Self {
177        Self {
178            run_id: run_id.to_string(),
179            step_id: step_id.to_string(),
180            status: StepStatus::Pending,
181            started_at: None,
182            completed_at: None,
183            duration_ms: None,
184            state_snapshot: None,
185            output_log: None,
186            error_message: None,
187        }
188    }
189
190    /// Mark the step as running
191    pub fn start(&mut self) {
192        self.status = StepStatus::Running;
193        self.started_at = Some(current_timestamp());
194    }
195
196    /// Mark the step as completed
197    pub fn complete(&mut self, output: Option<String>) {
198        self.status = StepStatus::Completed;
199        self.completed_at = Some(current_timestamp());
200        self.output_log = output;
201        if let (Some(start), Some(end)) = (self.started_at, self.completed_at) {
202            self.duration_ms = Some((end - start) * 1000);
203        }
204    }
205
206    /// Mark the step as failed
207    pub fn fail(&mut self, error: &str) {
208        self.status = StepStatus::Failed;
209        self.completed_at = Some(current_timestamp());
210        self.error_message = Some(error.to_string());
211        if let (Some(start), Some(end)) = (self.started_at, self.completed_at) {
212            self.duration_ms = Some((end - start) * 1000);
213        }
214    }
215
216    /// Mark the step as skipped
217    pub fn skip(&mut self) {
218        self.status = StepStatus::Skipped;
219    }
220}
221
222/// Tracked file state for rollback
223#[derive(Debug, Clone)]
224pub struct StateFile {
225    /// Run this file state belongs to
226    pub run_id: String,
227    /// Step that modified this file
228    pub step_id: String,
229    /// Path to the file
230    pub file_path: PathBuf,
231    /// SHA256 hash of content
232    pub content_hash: String,
233    /// When the backup was created
234    pub backed_up_at: Option<u64>,
235    /// Path to the backup file
236    pub backup_path: Option<PathBuf>,
237}
238
239impl StateFile {
240    /// Create a new state file record
241    pub fn new(run_id: &str, step_id: &str, file_path: &Path, content_hash: &str) -> Self {
242        Self {
243            run_id: run_id.to_string(),
244            step_id: step_id.to_string(),
245            file_path: file_path.to_path_buf(),
246            content_hash: content_hash.to_string(),
247            backed_up_at: None,
248            backup_path: None,
249        }
250    }
251
252    /// Mark as backed up
253    pub fn set_backup(&mut self, backup_path: &Path) {
254        self.backed_up_at = Some(current_timestamp());
255        self.backup_path = Some(backup_path.to_path_buf());
256    }
257}
258
259/// Checkpoint storage manager
260#[derive(Debug)]
261pub struct CheckpointStore {
262    /// Path to the checkpoint directory
263    checkpoint_dir: PathBuf,
264    /// Current run (if any)
265    current_run: Option<InstallerRun>,
266    /// Step checkpoints for current run
267    steps: Vec<StepCheckpoint>,
268    /// State files for current run
269    state_files: Vec<StateFile>,
270}
271
272impl CheckpointStore {
273    /// Create a new checkpoint store
274    pub fn new(checkpoint_dir: &Path) -> Result<Self> {
275        // Create checkpoint directory if it doesn't exist
276        std::fs::create_dir_all(checkpoint_dir).map_err(|e| {
277            Error::Io(std::io::Error::new(
278                e.kind(),
279                format!("Failed to create checkpoint directory: {}", e),
280            ))
281        })?;
282
283        Ok(Self {
284            checkpoint_dir: checkpoint_dir.to_path_buf(),
285            current_run: None,
286            steps: Vec::new(),
287            state_files: Vec::new(),
288        })
289    }
290
291    /// Start a new installer run
292    pub fn start_run(&mut self, installer_name: &str, installer_version: &str) -> Result<String> {
293        let run = InstallerRun::new(installer_name, installer_version);
294        let run_id = run.run_id.clone();
295        self.current_run = Some(run);
296        self.steps.clear();
297        self.state_files.clear();
298        self.save()?;
299        Ok(run_id)
300    }
301
302    /// Start a hermetic installer run
303    pub fn start_hermetic_run(
304        &mut self,
305        installer_name: &str,
306        installer_version: &str,
307        lockfile_hash: &str,
308    ) -> Result<String> {
309        let run = InstallerRun::new_hermetic(installer_name, installer_version, lockfile_hash);
310        let run_id = run.run_id.clone();
311        self.current_run = Some(run);
312        self.steps.clear();
313        self.state_files.clear();
314        self.save()?;
315        Ok(run_id)
316    }
317
318    /// Get the current run ID
319    pub fn current_run_id(&self) -> Option<&str> {
320        self.current_run.as_ref().map(|r| r.run_id.as_str())
321    }
322
323    /// Add a step checkpoint
324    pub fn add_step(&mut self, step_id: &str) -> Result<()> {
325        let run_id = self
326            .current_run
327            .as_ref()
328            .ok_or_else(|| Error::Validation("No active run".to_string()))?
329            .run_id
330            .clone();
331
332        let checkpoint = StepCheckpoint::new(&run_id, step_id);
333        self.steps.push(checkpoint);
334        self.save()
335    }
336
337    /// Start a step
338    pub fn start_step(&mut self, step_id: &str) -> Result<()> {
339        let step = self
340            .steps
341            .iter_mut()
342            .find(|s| s.step_id == step_id)
343            .ok_or_else(|| Error::Validation(format!("Step not found: {}", step_id)))?;
344
345        step.start();
346        self.save()
347    }
348
349    /// Complete a step
350    pub fn complete_step(&mut self, step_id: &str, output: Option<String>) -> Result<()> {
351        let step = self
352            .steps
353            .iter_mut()
354            .find(|s| s.step_id == step_id)
355            .ok_or_else(|| Error::Validation(format!("Step not found: {}", step_id)))?;
356
357        step.complete(output);
358        self.save()
359    }
360
361    /// Fail a step
362    pub fn fail_step(&mut self, step_id: &str, error: &str) -> Result<()> {
363        let step = self
364            .steps
365            .iter_mut()
366            .find(|s| s.step_id == step_id)
367            .ok_or_else(|| Error::Validation(format!("Step not found: {}", step_id)))?;
368
369        step.fail(error);
370
371        // Also mark the run as failed
372        if let Some(ref mut run) = self.current_run {
373            run.fail();
374        }
375
376        self.save()
377    }
378
379    /// Complete the run
380    pub fn complete_run(&mut self) -> Result<()> {
381        if let Some(ref mut run) = self.current_run {
382            run.complete();
383        }
384        self.save()
385    }
386
387    /// Get the last successful step
388    pub fn last_successful_step(&self) -> Option<&StepCheckpoint> {
389        self.steps
390            .iter()
391            .rev()
392            .find(|s| s.status == StepStatus::Completed)
393    }
394
395    /// Get step by ID
396    pub fn get_step(&self, step_id: &str) -> Option<&StepCheckpoint> {
397        self.steps.iter().find(|s| s.step_id == step_id)
398    }
399
400    /// Get all steps
401    pub fn steps(&self) -> &[StepCheckpoint] {
402        &self.steps
403    }
404
405    /// Track a state file
406    pub fn track_file(
407        &mut self,
408        step_id: &str,
409        file_path: &Path,
410        content_hash: &str,
411    ) -> Result<()> {
412        let run_id = self
413            .current_run
414            .as_ref()
415            .ok_or_else(|| Error::Validation("No active run".to_string()))?
416            .run_id
417            .clone();
418
419        let state_file = StateFile::new(&run_id, step_id, file_path, content_hash);
420        self.state_files.push(state_file);
421        self.save()
422    }
423
424    /// Get state files for a step
425    pub fn state_files_for_step(&self, step_id: &str) -> Vec<&StateFile> {
426        self.state_files
427            .iter()
428            .filter(|sf| sf.step_id == step_id)
429            .collect()
430    }
431
432    /// Check if in hermetic mode
433    pub fn is_hermetic(&self) -> bool {
434        self.current_run
435            .as_ref()
436            .map(|r| r.hermetic_mode)
437            .unwrap_or(false)
438    }
439
440    /// Verify hermetic mode consistency
441    pub fn verify_hermetic_consistency(&self, current_lockfile_hash: &str) -> Result<()> {
442        if let Some(ref run) = self.current_run {
443            if run.hermetic_mode {
444                if let Some(ref saved_hash) = run.lockfile_hash {
445                    if saved_hash != current_lockfile_hash {
446                        return Err(Error::Validation(format!(
447                            "Lockfile drift detected: checkpoint={}, current={}",
448                            saved_hash, current_lockfile_hash
449                        )));
450                    }
451                }
452            }
453        }
454        Ok(())
455    }
456
457    /// Save checkpoint to disk
458    fn save(&self) -> Result<()> {
459        // For now, save as JSON (SQLite integration in Phase 2.1)
460        let checkpoint_file = self.checkpoint_dir.join("checkpoint.json");
461
462        let data = CheckpointData {
463            run: self.current_run.clone(),
464            steps: self.steps.clone(),
465            state_files: self.state_files.clone(),
466        };
467
468        let json = serde_json::to_string_pretty(&data)
469            .map_err(|e| Error::Validation(format!("Failed to serialize checkpoint: {}", e)))?;
470
471        std::fs::write(&checkpoint_file, json).map_err(|e| {
472            Error::Io(std::io::Error::new(
473                e.kind(),
474                format!("Failed to write checkpoint: {}", e),
475            ))
476        })
477    }
478
479    /// Load checkpoint from disk
480    pub fn load(checkpoint_dir: &Path) -> Result<Self> {
481        let checkpoint_file = checkpoint_dir.join("checkpoint.json");
482
483        if !checkpoint_file.exists() {
484            return Self::new(checkpoint_dir);
485        }
486
487        let json = std::fs::read_to_string(&checkpoint_file).map_err(|e| {
488            Error::Io(std::io::Error::new(
489                e.kind(),
490                format!("Failed to read checkpoint: {}", e),
491            ))
492        })?;
493
494        let data: CheckpointData = serde_json::from_str(&json)
495            .map_err(|e| Error::Validation(format!("Failed to parse checkpoint: {}", e)))?;
496
497        Ok(Self {
498            checkpoint_dir: checkpoint_dir.to_path_buf(),
499            current_run: data.run,
500            steps: data.steps,
501            state_files: data.state_files,
502        })
503    }
504}
505
506/// Serializable checkpoint data
507#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
508struct CheckpointData {
509    run: Option<InstallerRun>,
510    steps: Vec<StepCheckpoint>,
511    state_files: Vec<StateFile>,
512}
513
514// Implement serde for our types
515impl serde::Serialize for InstallerRun {
516    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
517    where
518        S: serde::Serializer,
519    {
520        use serde::ser::SerializeStruct;
521        let mut state = serializer.serialize_struct("InstallerRun", 8)?;
522        state.serialize_field("run_id", &self.run_id)?;
523        state.serialize_field("installer_name", &self.installer_name)?;
524        state.serialize_field("installer_version", &self.installer_version)?;
525        state.serialize_field("started_at", &self.started_at)?;
526        state.serialize_field("completed_at", &self.completed_at)?;
527        state.serialize_field("status", &self.status.as_str())?;
528        state.serialize_field("hermetic_mode", &self.hermetic_mode)?;
529        state.serialize_field("lockfile_hash", &self.lockfile_hash)?;
530        state.end()
531    }
532}
533
534impl<'de> serde::Deserialize<'de> for InstallerRun {
535    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
536    where
537        D: serde::Deserializer<'de>,
538    {
539        #[derive(serde::Deserialize)]
540        struct RunHelper {
541            run_id: String,
542            installer_name: String,
543            installer_version: String,
544            started_at: u64,
545            completed_at: Option<u64>,
546            status: String,
547            hermetic_mode: bool,
548            lockfile_hash: Option<String>,
549        }
550
551        let helper = RunHelper::deserialize(deserializer)?;
552        let status = RunStatus::parse(&helper.status)
553            .ok_or_else(|| serde::de::Error::custom("Invalid status"))?;
554
555        Ok(InstallerRun {
556            run_id: helper.run_id,
557            installer_name: helper.installer_name,
558            installer_version: helper.installer_version,
559            started_at: helper.started_at,
560            completed_at: helper.completed_at,
561            status,
562            hermetic_mode: helper.hermetic_mode,
563            lockfile_hash: helper.lockfile_hash,
564        })
565    }
566}
567
568impl serde::Serialize for StepCheckpoint {
569    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
570    where
571        S: serde::Serializer,
572    {
573        use serde::ser::SerializeStruct;
574        let mut state = serializer.serialize_struct("StepCheckpoint", 9)?;
575        state.serialize_field("run_id", &self.run_id)?;
576        state.serialize_field("step_id", &self.step_id)?;
577        state.serialize_field("status", &self.status.as_str())?;
578        state.serialize_field("started_at", &self.started_at)?;
579        state.serialize_field("completed_at", &self.completed_at)?;
580        state.serialize_field("duration_ms", &self.duration_ms)?;
581        state.serialize_field("state_snapshot", &self.state_snapshot)?;
582        state.serialize_field("output_log", &self.output_log)?;
583        state.serialize_field("error_message", &self.error_message)?;
584        state.end()
585    }
586}
587
588impl<'de> serde::Deserialize<'de> for StepCheckpoint {
589    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
590    where
591        D: serde::Deserializer<'de>,
592    {
593        #[derive(serde::Deserialize)]
594        struct StepHelper {
595            run_id: String,
596            step_id: String,
597            status: String,
598            started_at: Option<u64>,
599            completed_at: Option<u64>,
600            duration_ms: Option<u64>,
601            state_snapshot: Option<String>,
602            output_log: Option<String>,
603            error_message: Option<String>,
604        }
605
606        let helper = StepHelper::deserialize(deserializer)?;
607        let status = StepStatus::parse(&helper.status)
608            .ok_or_else(|| serde::de::Error::custom("Invalid status"))?;
609
610        Ok(StepCheckpoint {
611            run_id: helper.run_id,
612            step_id: helper.step_id,
613            status,
614            started_at: helper.started_at,
615            completed_at: helper.completed_at,
616            duration_ms: helper.duration_ms,
617            state_snapshot: helper.state_snapshot,
618            output_log: helper.output_log,
619            error_message: helper.error_message,
620        })
621    }
622}
623
624impl serde::Serialize for StateFile {
625    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
626    where
627        S: serde::Serializer,
628    {
629        use serde::ser::SerializeStruct;
630        let mut state = serializer.serialize_struct("StateFile", 6)?;
631        state.serialize_field("run_id", &self.run_id)?;
632        state.serialize_field("step_id", &self.step_id)?;
633        state.serialize_field("file_path", &self.file_path)?;
634        state.serialize_field("content_hash", &self.content_hash)?;
635        state.serialize_field("backed_up_at", &self.backed_up_at)?;
636        state.serialize_field("backup_path", &self.backup_path)?;
637        state.end()
638    }
639}
640
641impl<'de> serde::Deserialize<'de> for StateFile {
642    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
643    where
644        D: serde::Deserializer<'de>,
645    {
646        #[derive(serde::Deserialize)]
647        struct FileHelper {
648            run_id: String,
649            step_id: String,
650            file_path: PathBuf,
651            content_hash: String,
652            backed_up_at: Option<u64>,
653            backup_path: Option<PathBuf>,
654        }
655
656        let helper = FileHelper::deserialize(deserializer)?;
657
658        Ok(StateFile {
659            run_id: helper.run_id,
660            step_id: helper.step_id,
661            file_path: helper.file_path,
662            content_hash: helper.content_hash,
663            backed_up_at: helper.backed_up_at,
664            backup_path: helper.backup_path,
665        })
666    }
667}
668
669/// Generate a unique run ID
670fn generate_run_id() -> String {
671    use std::hash::{Hash, Hasher};
672    let mut hasher = std::collections::hash_map::DefaultHasher::new();
673    current_timestamp().hash(&mut hasher);
674    std::process::id().hash(&mut hasher);
675    format!("run-{:016x}", hasher.finish())
676}
677
678/// Get current timestamp as seconds since epoch
679fn current_timestamp() -> u64 {
680    SystemTime::now()
681        .duration_since(UNIX_EPOCH)
682        .map(|d| d.as_secs())
683        .unwrap_or(0)
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use tempfile::TempDir;
690
691    // =========================================================================
692    // RED Phase: Failing Tests First (EXTREME TDD)
693    // Test naming: test_<TASK_ID>_<feature>_<scenario>
694    // TASK_ID: CHECKPOINT_106
695    // =========================================================================
696
697    #[test]
698    fn test_CHECKPOINT_106_create_store() {
699        let temp_dir = TempDir::new().unwrap();
700        let store = CheckpointStore::new(temp_dir.path()).unwrap();
701        assert!(store.current_run_id().is_none());
702    }
703
704    #[test]
705    fn test_CHECKPOINT_106_start_run() {
706        let temp_dir = TempDir::new().unwrap();
707        let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
708
709        let run_id = store.start_run("my-installer", "1.0.0").unwrap();
710        assert!(run_id.starts_with("run-"));
711        assert!(store.current_run_id().is_some());
712    }
713
714    #[test]
715    fn test_CHECKPOINT_106_add_step() {
716        let temp_dir = TempDir::new().unwrap();
717        let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
718
719        store.start_run("my-installer", "1.0.0").unwrap();
720        store.add_step("step-1").unwrap();
721
722        let step = store.get_step("step-1").unwrap();
723        assert_eq!(step.status, StepStatus::Pending);
724    }
725
726    #[test]
727    fn test_CHECKPOINT_106_step_lifecycle() {
728        let temp_dir = TempDir::new().unwrap();
729        let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
730
731        store.start_run("my-installer", "1.0.0").unwrap();
732        store.add_step("step-1").unwrap();
733
734        // Start
735        store.start_step("step-1").unwrap();
736        assert_eq!(
737            store.get_step("step-1").unwrap().status,
738            StepStatus::Running
739        );
740
741        // Complete
742        store
743            .complete_step("step-1", Some("output".to_string()))
744            .unwrap();
745        let step = store.get_step("step-1").unwrap();
746        assert_eq!(step.status, StepStatus::Completed);
747        assert_eq!(step.output_log, Some("output".to_string()));
748    }
749
750    #[test]
751    fn test_CHECKPOINT_106_step_failure() {
752        let temp_dir = TempDir::new().unwrap();
753        let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
754
755        store.start_run("my-installer", "1.0.0").unwrap();
756        store.add_step("step-1").unwrap();
757        store.start_step("step-1").unwrap();
758        store.fail_step("step-1", "Something went wrong").unwrap();
759
760        let step = store.get_step("step-1").unwrap();
761        assert_eq!(step.status, StepStatus::Failed);
762        assert_eq!(step.error_message, Some("Something went wrong".to_string()));
763    }
764
765    #[test]
766    fn test_CHECKPOINT_106_last_successful_step() {
767        let temp_dir = TempDir::new().unwrap();
768        let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
769
770        store.start_run("my-installer", "1.0.0").unwrap();
771
772        store.add_step("step-1").unwrap();
773        store.start_step("step-1").unwrap();
774        store.complete_step("step-1", None).unwrap();
775
776        store.add_step("step-2").unwrap();
777        store.start_step("step-2").unwrap();
778        store.complete_step("step-2", None).unwrap();
779
780        store.add_step("step-3").unwrap();
781        store.start_step("step-3").unwrap();
782        store.fail_step("step-3", "error").unwrap();
783
784        let last = store.last_successful_step().unwrap();
785        assert_eq!(last.step_id, "step-2");
786    }
787
788    #[test]
789    fn test_CHECKPOINT_106_hermetic_mode() {
790        let temp_dir = TempDir::new().unwrap();
791        let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
792
793        store
794            .start_hermetic_run("my-installer", "1.0.0", "abc123")
795            .unwrap();
796        assert!(store.is_hermetic());
797
798        // Verify consistency with same hash
799        store.verify_hermetic_consistency("abc123").unwrap();
800
801        // Verify fails with different hash
802        let result = store.verify_hermetic_consistency("different");
803        assert!(result.is_err());
804    }
805
806    #[test]
807    fn test_CHECKPOINT_106_track_file() {
808        let temp_dir = TempDir::new().unwrap();
809        let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
810
811        store.start_run("my-installer", "1.0.0").unwrap();
812        store.add_step("step-1").unwrap();
813
814        store
815            .track_file("step-1", Path::new("/etc/config.txt"), "sha256:abc")
816            .unwrap();
817
818        let files = store.state_files_for_step("step-1");
819        assert_eq!(files.len(), 1);
820        assert_eq!(files[0].content_hash, "sha256:abc");
821    }
822
823    #[test]
824    fn test_CHECKPOINT_106_persistence() {
825        let temp_dir = TempDir::new().unwrap();
826
827        // Create and populate store
828        {
829            let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
830            store.start_run("my-installer", "1.0.0").unwrap();
831            store.add_step("step-1").unwrap();
832            store.start_step("step-1").unwrap();
833            store
834                .complete_step("step-1", Some("done".to_string()))
835                .unwrap();
836        }
837
838        // Load from disk
839        {
840            let store = CheckpointStore::load(temp_dir.path()).unwrap();
841            assert!(store.current_run_id().is_some());
842            let step = store.get_step("step-1").unwrap();
843            assert_eq!(step.status, StepStatus::Completed);
844        }
845    }
846
847    #[test]
848    fn test_CHECKPOINT_106_run_status_roundtrip() {
849        for status in [
850            RunStatus::Running,
851            RunStatus::Completed,
852            RunStatus::Failed,
853            RunStatus::Aborted,
854        ] {
855            let s = status.as_str();
856            assert_eq!(RunStatus::parse(s), Some(status));
857        }
858    }
859
860    #[test]
861    fn test_CHECKPOINT_106_step_status_roundtrip() {
862        for status in [
863            StepStatus::Pending,
864            StepStatus::Running,
865            StepStatus::Completed,
866            StepStatus::Failed,
867            StepStatus::Skipped,
868        ] {
869            let s = status.as_str();
870            assert_eq!(StepStatus::parse(s), Some(status));
871        }
872    }
873}
874
875#[cfg(test)]
876mod property_tests {
877    use super::*;
878    use proptest::prelude::*;
879    use tempfile::TempDir;
880
881    proptest! {
882        /// Property: Store never panics on any installer name
883        #[test]
884        fn prop_store_handles_any_name(name in ".*") {
885            let temp_dir = TempDir::new().unwrap();
886            let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
887            // Should not panic
888            let _ = store.start_run(&name, "1.0.0");
889        }
890
891        /// Property: Step IDs are preserved exactly
892        #[test]
893        fn prop_step_id_preserved(step_id in "[a-zA-Z][a-zA-Z0-9_-]{0,50}") {
894            let temp_dir = TempDir::new().unwrap();
895            let mut store = CheckpointStore::new(temp_dir.path()).unwrap();
896            store.start_run("test", "1.0.0").unwrap();
897            store.add_step(&step_id).unwrap();
898
899            let step = store.get_step(&step_id);
900            prop_assert!(step.is_some());
901            prop_assert_eq!(&step.unwrap().step_id, &step_id);
902        }
903    }
904}