use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatchupState {
pub last_catchup_at: DateTime<Utc>,
pub palace_id: String,
pub last_git_sha: Option<String>,
}
fn catchup_dir(palace_id: &str, state_root: Option<&Path>) -> Option<PathBuf> {
let root = match state_root {
Some(root) => root.to_path_buf(),
None => dirs::home_dir()?.join(".trusty-mpm"),
};
Some(root.join("projects").join(palace_id))
}
fn state_path(palace_id: &str, state_root: Option<&Path>) -> Option<PathBuf> {
Some(catchup_dir(palace_id, state_root)?.join("catchup-state.json"))
}
pub fn load_catchup_state(palace_id: &str) -> Option<CatchupState> {
load_catchup_state_in(palace_id, None)
}
pub fn load_catchup_state_in(palace_id: &str, state_root: Option<&Path>) -> Option<CatchupState> {
let path = state_path(palace_id, state_root)?;
let bytes = std::fs::read(&path).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn save_catchup_state(palace_id: &str, state: &CatchupState) -> anyhow::Result<()> {
save_catchup_state_in(palace_id, state, None)
}
pub fn save_catchup_state_in(
palace_id: &str,
state: &CatchupState,
state_root: Option<&Path>,
) -> anyhow::Result<()> {
let dir = catchup_dir(palace_id, state_root)
.ok_or_else(|| anyhow::anyhow!("could not resolve home directory for catchup state"))?;
std::fs::create_dir_all(&dir)?;
let path = dir.join("catchup-state.json");
let json = serde_json::to_vec_pretty(state)?;
std::fs::write(&path, json)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample(palace_id: &str) -> CatchupState {
CatchupState {
last_catchup_at: "2026-06-27T10:00:00Z".parse::<DateTime<Utc>>().unwrap(),
palace_id: palace_id.to_string(),
last_git_sha: Some("abc1234".to_string()),
}
}
#[test]
fn state_save_load_roundtrip() {
let tmp = TempDir::new().unwrap();
let state = sample("test-palace");
save_catchup_state_in("test-palace", &state, Some(tmp.path())).unwrap();
let loaded =
load_catchup_state_in("test-palace", Some(tmp.path())).expect("state should load back");
assert_eq!(loaded.palace_id, "test-palace");
assert_eq!(loaded.last_git_sha.as_deref(), Some("abc1234"));
}
#[test]
fn load_wrapper_delegates_with_the_home_default() {
assert_eq!(
load_catchup_state("no-such-palace-6501").is_none(),
load_catchup_state_in("no-such-palace-6501", None).is_none(),
);
assert!(load_catchup_state("no-such-palace-6501").is_none());
}
#[test]
fn save_writes_under_the_state_root() {
let tmp = TempDir::new().unwrap();
save_catchup_state_in("pinned-palace", &sample("pinned-palace"), Some(tmp.path())).unwrap();
assert!(
tmp.path()
.join("projects")
.join("pinned-palace")
.join("catchup-state.json")
.is_file(),
"the watermark must be written under the state root"
);
}
#[test]
fn catchup_dir_falls_back_to_home() {
let Some(home) = dirs::home_dir() else {
return; };
assert_eq!(
catchup_dir("p", None),
Some(home.join(".trusty-mpm").join("projects").join("p"))
);
}
#[test]
fn catchup_dir_honours_state_root() {
let tmp = TempDir::new().unwrap();
assert_eq!(
catchup_dir("p", Some(tmp.path())),
Some(tmp.path().join("projects").join("p"))
);
}
#[test]
fn state_missing_file_returns_none() {
let tmp = TempDir::new().unwrap();
assert!(load_catchup_state_in("never-written", Some(tmp.path())).is_none());
}
#[test]
fn state_parse_failure_returns_none() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("projects").join("test-palace");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("catchup-state.json"), b"not valid json {{").unwrap();
assert!(
load_catchup_state_in("test-palace", Some(tmp.path())).is_none(),
"invalid JSON should yield None"
);
}
}