use std::collections::BTreeMap;
use camino::Utf8PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{NewgitError, Result};
use crate::materializer::create_dir_all;
use crate::store::{read_dir_sorted, read_toml_at, write_toml_at};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CheckpointRecord {
pub id: String,
pub branch: String,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
pub reason: CheckpointReason,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub undo_completed: Option<bool>,
pub source: SourceState,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tracker_states: Vec<TrackerState>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resource_states: Vec<ResourceState>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum CheckpointReason {
Explicit,
BeforeUndo,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SourceState {
pub head_rev: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dirty_rev: Option<String>,
pub store_ref: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrackerState {
pub name: String,
pub definition_rev: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_rev: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResourceState {
pub name: String,
pub definition_rev: String,
pub mode: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_path: Option<Utf8PathBuf>,
#[serde(default)]
pub was_running: bool,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub resolved_ports: BTreeMap<String, u16>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub resolved_exports: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RecoveryRecord {
pub checkpoint: String,
pub branch: String,
pub created_at: DateTime<Utc>,
pub failures: Vec<RestoreFailure>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RestoreFailure {
pub resource: String,
pub detail: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log: Option<Utf8PathBuf>,
pub retry_with: String,
}
#[derive(Debug, Clone)]
pub struct CheckpointLog {
dir: Utf8PathBuf,
instance: String,
}
impl CheckpointLog {
pub fn new(dir: Utf8PathBuf, instance: &str) -> Self {
Self {
dir,
instance: instance.to_owned(),
}
}
pub fn list(&self) -> Result<Vec<CheckpointRecord>> {
let mut records: Vec<CheckpointRecord> = Vec::new();
for entry in read_dir_sorted(&self.dir)? {
if entry.extension() == Some("toml")
&& entry
.file_name()
.is_some_and(|name| name.starts_with("ckpt_") && !name.contains(".recovery."))
{
records.push(read_toml_at(&entry)?);
}
}
records.sort_by_key(|record| numeric_id(&record.id));
Ok(records)
}
pub fn load(&self, id: &str) -> Result<CheckpointRecord> {
let path = self.record_path(id);
if !path.is_file() {
return Err(NewgitError::UnknownCheckpoint {
instance: self.instance.clone(),
id: id.to_owned(),
});
}
read_toml_at(&path)
}
pub fn latest(&self) -> Result<CheckpointRecord> {
self.list()?
.into_iter()
.next_back()
.ok_or_else(|| NewgitError::NoCheckpoints(self.instance.clone()))
}
pub fn next_id(&self) -> Result<String> {
let last = self
.list()?
.last()
.map(|record| numeric_id(&record.id))
.unwrap_or(0);
Ok(format!("ckpt_{:03}", last + 1))
}
pub fn save(&self, record: &CheckpointRecord) -> Result<Utf8PathBuf> {
create_dir_all(&self.dir)?;
let path = self.record_path(&record.id);
write_toml_at(&path, &format!("checkpoint `{}`", record.id), record)?;
Ok(path)
}
pub fn save_recovery(&self, record: &RecoveryRecord) -> Result<Utf8PathBuf> {
create_dir_all(&self.dir)?;
let path = self
.dir
.join(format!("{}.recovery.toml", record.checkpoint));
write_toml_at(
&path,
&format!("recovery record for `{}`", record.checkpoint),
record,
)?;
Ok(path)
}
fn record_path(&self, id: &str) -> Utf8PathBuf {
self.dir.join(format!("{id}.toml"))
}
}
fn numeric_id(id: &str) -> u64 {
id.rsplit('_')
.next()
.and_then(|suffix| suffix.parse().ok())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_sequential_and_survive_a_roundtrip() {
let temp = tempfile::tempdir().expect("tempdir");
let dir = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
let log = CheckpointLog::new(dir.join("feature-a"), "feature-a");
assert_eq!(log.next_id().expect("next"), "ckpt_001");
assert!(matches!(log.latest(), Err(NewgitError::NoCheckpoints(_))));
let record = CheckpointRecord {
id: "ckpt_001".to_owned(),
branch: "feature-a".to_owned(),
created_at: Utc::now(),
message: Some("before auth refactor".to_owned()),
reason: CheckpointReason::Explicit,
undo_completed: None,
source: SourceState {
head_rev: "abc".to_owned(),
dirty_rev: None,
store_ref: "refs/newgit/checkpoints/feature-a/ckpt_001".to_owned(),
},
tracker_states: vec![],
resource_states: vec![],
};
log.save(&record).expect("save");
assert_eq!(log.next_id().expect("next"), "ckpt_002");
assert_eq!(log.latest().expect("latest"), record);
assert_eq!(log.load("ckpt_001").expect("load"), record);
assert!(matches!(
log.load("ckpt_009"),
Err(NewgitError::UnknownCheckpoint { .. })
));
}
}