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