1use std::collections::BTreeMap;
2
3use camino::Utf8PathBuf;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use crate::error::{NewgitError, Result};
8use crate::materializer::create_dir_all;
9use crate::store::{read_dir_sorted, read_toml_at, write_toml_at};
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
15pub struct CheckpointRecord {
16 pub id: String,
17 pub branch: String,
18 pub created_at: DateTime<Utc>,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub message: Option<String>,
21 pub reason: CheckpointReason,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub undo_completed: Option<bool>,
33 pub source: SourceState,
34 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub tracker_states: Vec<TrackerState>,
36 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub resource_states: Vec<ResourceState>,
38}
39
40#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "kebab-case")]
42pub enum CheckpointReason {
43 Explicit,
44 BeforeUndo,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50pub struct SourceState {
51 pub head_rev: String,
53 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub dirty_rev: Option<String>,
57 pub store_ref: String,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub struct TrackerState {
63 pub name: String,
64 pub definition_rev: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub content_rev: Option<String>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct ResourceState {
73 pub name: String,
74 pub definition_rev: String,
75 pub mode: String,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub state_ref: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub state_path: Option<Utf8PathBuf>,
84 #[serde(default)]
86 pub was_running: bool,
87 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
88 pub resolved_ports: BTreeMap<String, u16>,
89 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
90 pub resolved_exports: BTreeMap<String, String>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97pub struct RecoveryRecord {
98 pub checkpoint: String,
99 pub branch: String,
100 pub created_at: DateTime<Utc>,
101 pub failures: Vec<RestoreFailure>,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct RestoreFailure {
106 pub resource: String,
107 pub detail: String,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub log: Option<Utf8PathBuf>,
110 pub retry_with: String,
112}
113
114#[derive(Debug, Clone)]
116pub struct CheckpointLog {
117 dir: Utf8PathBuf,
118 instance: String,
119}
120
121impl CheckpointLog {
122 pub fn new(dir: Utf8PathBuf, instance: &str) -> Self {
124 Self {
125 dir,
126 instance: instance.to_owned(),
127 }
128 }
129
130 pub fn list(&self) -> Result<Vec<CheckpointRecord>> {
132 let mut records: Vec<CheckpointRecord> = Vec::new();
133 for entry in read_dir_sorted(&self.dir)? {
134 if entry.extension() == Some("toml")
135 && entry
136 .file_name()
137 .is_some_and(|name| name.starts_with("ckpt_") && !name.contains(".recovery."))
138 {
139 records.push(read_toml_at(&entry)?);
140 }
141 }
142 records.sort_by_key(|record| numeric_id(&record.id));
143 Ok(records)
144 }
145
146 pub fn load(&self, id: &str) -> Result<CheckpointRecord> {
147 let path = self.record_path(id);
148 if !path.is_file() {
149 return Err(NewgitError::UnknownCheckpoint {
150 instance: self.instance.clone(),
151 id: id.to_owned(),
152 });
153 }
154 read_toml_at(&path)
155 }
156
157 pub fn latest(&self) -> Result<CheckpointRecord> {
158 self.list()?
159 .into_iter()
160 .next_back()
161 .ok_or_else(|| NewgitError::NoCheckpoints(self.instance.clone()))
162 }
163
164 pub fn next_id(&self) -> Result<String> {
166 let last = self
167 .list()?
168 .last()
169 .map(|record| numeric_id(&record.id))
170 .unwrap_or(0);
171 Ok(format!("ckpt_{:03}", last + 1))
172 }
173
174 pub fn save(&self, record: &CheckpointRecord) -> Result<Utf8PathBuf> {
175 create_dir_all(&self.dir)?;
176 let path = self.record_path(&record.id);
177 write_toml_at(&path, &format!("checkpoint `{}`", record.id), record)?;
178 Ok(path)
179 }
180
181 pub fn save_recovery(&self, record: &RecoveryRecord) -> Result<Utf8PathBuf> {
182 create_dir_all(&self.dir)?;
183 let path = self
184 .dir
185 .join(format!("{}.recovery.toml", record.checkpoint));
186 write_toml_at(
187 &path,
188 &format!("recovery record for `{}`", record.checkpoint),
189 record,
190 )?;
191 Ok(path)
192 }
193
194 fn record_path(&self, id: &str) -> Utf8PathBuf {
195 self.dir.join(format!("{id}.toml"))
196 }
197}
198
199fn numeric_id(id: &str) -> u64 {
200 id.rsplit('_')
201 .next()
202 .and_then(|suffix| suffix.parse().ok())
203 .unwrap_or(0)
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[test]
211 fn ids_are_sequential_and_survive_a_roundtrip() {
212 let temp = tempfile::tempdir().expect("tempdir");
213 let dir = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
214 let log = CheckpointLog::new(dir.join("feature-a"), "feature-a");
215
216 assert_eq!(log.next_id().expect("next"), "ckpt_001");
217 assert!(matches!(log.latest(), Err(NewgitError::NoCheckpoints(_))));
218
219 let record = CheckpointRecord {
220 id: "ckpt_001".to_owned(),
221 branch: "feature-a".to_owned(),
222 created_at: Utc::now(),
223 message: Some("before auth refactor".to_owned()),
224 reason: CheckpointReason::Explicit,
225 undo_completed: None,
226 source: SourceState {
227 head_rev: "abc".to_owned(),
228 dirty_rev: None,
229 store_ref: "refs/newgit/checkpoints/feature-a/ckpt_001".to_owned(),
230 },
231 tracker_states: vec![],
232 resource_states: vec![],
233 };
234 log.save(&record).expect("save");
235
236 assert_eq!(log.next_id().expect("next"), "ckpt_002");
237 assert_eq!(log.latest().expect("latest"), record);
238 assert_eq!(log.load("ckpt_001").expect("load"), record);
239 assert!(matches!(
240 log.load("ckpt_009"),
241 Err(NewgitError::UnknownCheckpoint { .. })
242 ));
243 }
244}