Skip to main content

horto_os_ui_shared/
resume.rs

1//! Persist and query per-step completion for setup resume.
2
3use crate::error::Result;
4use crate::paths::HostPaths;
5use crate::step::Step;
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::fs;
9use std::path::Path;
10
11/// Serialized resume file: ordered step completion records.
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13pub struct ResumeState {
14    /// Completion / failure records (latest per id wins for [`status_for`]).
15    pub steps: Vec<ResumeStepRecord>,
16}
17
18/// One recorded attempt for a step id.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ResumeStepRecord {
21    /// Step id (`s1`, `d1`, …).
22    pub id: String,
23    /// [`Step::step_version`] at record time.
24    pub step_version: u32,
25    /// UTC timestamp when the record was written.
26    pub completed_at: DateTime<Utc>,
27    /// `true` on success, `false` on recorded failure.
28    pub ok: bool,
29}
30
31/// Derived status of a registered step against [`ResumeState`].
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum StepStatus {
34    /// No matching record (or only superseded entries).
35    Pending,
36    /// Last record is ok and `step_version` is current or newer than the binary's.
37    Done,
38    /// Last record is ok but recorded version is older than the binary's.
39    Stale,
40    /// Last record has `ok: false`.
41    Failed,
42}
43
44/// Load resume JSON from `path`, or an empty state when the file is missing.
45///
46/// # Errors
47///
48/// Returns [`HortoError::Io`](crate::error::HortoError::Io) on read failure, or
49/// [`HortoError::Json`](crate::error::HortoError::Json) when the file is not valid JSON.
50pub fn load(path: &Path) -> Result<ResumeState> {
51    if !path.exists() {
52        return Ok(ResumeState::default());
53    }
54    let text = fs::read_to_string(path)?;
55    Ok(serde_json::from_str(&text)?)
56}
57
58/// Write resume JSON (pretty) to `path`, creating parent directories as needed.
59///
60/// # Errors
61///
62/// Returns [`HortoError::Io`](crate::error::HortoError::Io) on directory/create/write failure, or
63/// [`HortoError::Json`](crate::error::HortoError::Json) if serialization fails.
64pub fn save(path: &Path, state: &ResumeState) -> Result<()> {
65    if let Some(parent) = path.parent() {
66        fs::create_dir_all(parent)?;
67    }
68    let text = serde_json::to_string_pretty(state)?;
69    fs::write(path, text)?;
70    Ok(())
71}
72
73/// Replace any prior record for `step` with a successful completion and save.
74///
75/// # Errors
76///
77/// Propagates [`load`] / [`save`] errors (`Io`, `Json`).
78pub fn record_ok(paths: &HostPaths, step: &dyn Step) -> Result<()> {
79    let path = paths.resume_file();
80    let mut state = load(&path)?;
81    state.steps.retain(|r| r.id != step.id());
82    state.steps.push(ResumeStepRecord {
83        id: step.id().to_string(),
84        step_version: step.step_version(),
85        completed_at: Utc::now(),
86        ok: true,
87    });
88    save(&path, &state)
89}
90
91/// Compute [`StepStatus`] from the latest matching record in `state`.
92#[must_use]
93pub fn status_for(state: &ResumeState, step: &dyn Step) -> StepStatus {
94    match state.steps.iter().rev().find(|r| r.id == step.id()) {
95        Some(r) if r.ok && r.step_version >= step.step_version() => StepStatus::Done,
96        Some(r) if r.ok && r.step_version < step.step_version() => StepStatus::Stale,
97        Some(r) if !r.ok => StepStatus::Failed,
98        _ => StepStatus::Pending,
99    }
100}
101
102/// True when recorded completion is older than the binary's `step_version`.
103#[must_use]
104pub const fn is_stale(recorded_version: u32, binary_version: u32) -> bool {
105    binary_version > recorded_version
106}
107
108/// Replace any prior record for `step_id` with a failure and save.
109///
110/// On load failure, starts from an empty [`ResumeState`] instead of aborting.
111///
112/// # Errors
113///
114/// Propagates [`save`] errors (`Io`, `Json`).
115pub fn mark_failed(paths: &HostPaths, step_id: &str, step_version: u32) -> Result<()> {
116    let path = paths.resume_file();
117    let mut state = load(&path).unwrap_or_default();
118    state.steps.retain(|r| r.id != step_id);
119    state.steps.push(ResumeStepRecord {
120        id: step_id.to_string(),
121        step_version,
122        completed_at: Utc::now(),
123        ok: false,
124    });
125    save(&path, &state)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn stale_when_binary_newer() {
134        assert!(is_stale(1, 2));
135        assert!(!is_stale(2, 2));
136        assert!(!is_stale(3, 2));
137    }
138}