use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
const FILE_NAME: &str = "state.toml";
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct State {
#[serde(skip_serializing_if = "Option::is_none")]
pub last_update_check: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_known_latest: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub last_known_outdated: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub update_check_disabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pending_update: Option<PendingUpdate>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingUpdate {
pub target_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub armed_from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub armed_at: Option<String>,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
pub fn default_state_dir() -> Option<PathBuf> {
let proj = ProjectDirs::from("io", "cartog", "cartog")?;
Some(
proj.state_dir()
.map(Path::to_path_buf)
.unwrap_or_else(|| proj.data_local_dir().to_path_buf()),
)
}
pub fn default_state_file() -> Option<PathBuf> {
Some(default_state_dir()?.join(FILE_NAME))
}
pub fn slot_for_db(prefix: &str, db_path: &Path) -> String {
use sha2::{Digest, Sha256};
let normalized = resolve_db_path_for_slot(db_path);
let mut hasher = Sha256::new();
hasher.update(normalized.as_os_str().as_encoded_bytes());
let digest = hasher.finalize();
let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
format!("{prefix}-{hex}")
}
fn resolve_db_path_for_slot(db_path: &Path) -> PathBuf {
if let Ok(canon) = db_path.canonicalize() {
return canon;
}
let mut suffix: Vec<&OsStr> = Vec::new();
for ancestor in db_path.ancestors() {
let is_implicit_cwd =
ancestor.as_os_str().is_empty() || ancestor.as_os_str() == std::ffi::OsStr::new(".");
if is_implicit_cwd {
if let Ok(cwd) = std::env::current_dir() {
let base = cwd.canonicalize().unwrap_or(cwd);
let mut result = base;
for component in suffix.iter().rev() {
result.push(component);
}
return result;
}
break;
}
if let Ok(canon_ancestor) = ancestor.canonicalize() {
let mut result = canon_ancestor;
for component in suffix.iter().rev() {
result.push(component);
}
return result;
}
if let Some(name) = ancestor.file_name() {
suffix.push(name);
} else {
break;
}
}
let mut normalized = PathBuf::new();
for component in db_path.components() {
match component {
std::path::Component::CurDir => {}
other => normalized.push(other.as_os_str()),
}
}
normalized
}
impl State {
pub fn load_from(path: &Path) -> Self {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"failed to read cartog state file; using defaults"
);
return Self::default();
}
};
match toml::from_str::<State>(&text) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"cartog state file is malformed; using defaults"
);
Self::default()
}
}
}
pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let serialized = toml::to_string(self).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("failed to serialise state: {e}"),
)
})?;
let tmp = match path.file_name() {
Some(name) => path.with_file_name(format!(
".{}.{}.tmp",
name.to_string_lossy(),
std::process::id(),
)),
None => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"state path has no file name",
));
}
};
use std::io::Write;
let mut f = std::fs::File::create(&tmp)?;
f.write_all(serialized.as_bytes())?;
f.sync_all()?;
drop(f);
std::fs::rename(&tmp, path)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use tempfile::TempDir;
#[test]
fn load_missing_file_returns_default() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
let state = State::load_from(&path);
assert_eq!(state, State::default());
}
#[test]
fn save_then_load_roundtrip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
let original = State {
last_update_check: Some("2026-05-06T14:32:00Z".to_string()),
last_known_latest: Some("0.14.0".to_string()),
last_known_outdated: true,
update_check_disabled: false,
pending_update: None,
};
original.save_to(&path).expect("save");
let loaded = State::load_from(&path);
assert_eq!(loaded, original);
}
#[test]
fn pending_update_roundtrips() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
let original = State {
pending_update: Some(PendingUpdate {
target_version: "0.20.0".to_string(),
armed_from: Some("0.19.0".to_string()),
armed_at: Some("2026-05-29T10:00:00Z".to_string()),
}),
..Default::default()
};
original.save_to(&path).expect("save");
let loaded = State::load_from(&path);
assert_eq!(loaded, original);
}
#[test]
fn default_state_omits_pending_update() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
State::default().save_to(&path).expect("save");
let text = std::fs::read_to_string(&path).unwrap();
assert!(
!text.contains("pending_update"),
"default state should not write pending_update, got: {text:?}"
);
}
#[test]
fn update_check_mutation_preserves_pending_update() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
State {
pending_update: Some(PendingUpdate {
target_version: "0.20.0".to_string(),
armed_from: Some("0.19.0".to_string()),
armed_at: None,
}),
..Default::default()
}
.save_to(&path)
.expect("seed armed state");
let mut state = State::load_from(&path);
state.last_update_check = Some("2026-05-29T12:00:00Z".to_string());
state.last_known_latest = Some("0.20.0".to_string());
state.last_known_outdated = true;
state.save_to(&path).expect("probe save");
let loaded = State::load_from(&path);
assert_eq!(
loaded
.pending_update
.as_ref()
.map(|p| p.target_version.as_str()),
Some("0.20.0"),
"the armed pending_update must survive the update-check save"
);
assert_eq!(loaded.last_known_latest.as_deref(), Some("0.20.0"));
}
#[test]
fn pending_update_inner_optionals_skip_when_none() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
State {
pending_update: Some(PendingUpdate {
target_version: "0.20.0".to_string(),
armed_from: None,
armed_at: None,
}),
..Default::default()
}
.save_to(&path)
.expect("save");
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("target_version"), "got: {text:?}");
assert!(
!text.contains("armed_from") && !text.contains("armed_at"),
"None inner fields must be omitted, got: {text:?}"
);
}
#[test]
fn unknown_fields_alongside_pending_update_still_load() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
std::fs::write(
&path,
"future_field = \"hello\"\n\n[pending_update]\ntarget_version = \"0.21.0\"\n",
)
.unwrap();
let state = State::load_from(&path);
assert_eq!(
state
.pending_update
.as_ref()
.map(|p| p.target_version.as_str()),
Some("0.21.0")
);
}
#[test]
fn save_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nested").join("subdir").join("state.toml");
State::default().save_to(&path).expect("save");
assert!(path.exists());
}
#[test]
fn malformed_toml_returns_default_without_panicking() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
std::fs::write(&path, "{{ not toml at all").unwrap();
let state = State::load_from(&path);
assert_eq!(state, State::default());
}
#[test]
fn binary_state_file_returns_default_without_panicking() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
std::fs::write(&path, [0xff, 0xfe, 0x00, 0x80, 0xc3, 0x28]).unwrap();
let state = State::load_from(&path);
assert_eq!(state, State::default());
}
#[test]
fn unknown_fields_are_ignored() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
std::fs::write(
&path,
"last_known_latest = \"0.15.0\"\nfuture_field = \"hello\"\n",
)
.unwrap();
let state = State::load_from(&path);
assert_eq!(state.last_known_latest.as_deref(), Some("0.15.0"));
}
#[test]
fn empty_file_loads_as_default() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
std::fs::write(&path, "").unwrap();
let state = State::load_from(&path);
assert_eq!(state, State::default());
}
#[test]
fn save_omits_default_fields_for_compactness() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
State::default().save_to(&path).expect("save");
let text = std::fs::read_to_string(&path).unwrap();
assert!(
!text.contains("last_update_check"),
"default state should not write last_update_check, got: {text:?}"
);
assert!(
!text.contains("last_known_outdated"),
"default state should not write last_known_outdated, got: {text:?}"
);
}
#[test]
fn save_overwrites_existing_atomically() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("state.toml");
State {
last_known_latest: Some("0.13.0".to_string()),
..Default::default()
}
.save_to(&path)
.expect("first save");
State {
last_known_latest: Some("0.14.0".to_string()),
..Default::default()
}
.save_to(&path)
.expect("second save");
let loaded = State::load_from(&path);
assert_eq!(loaded.last_known_latest.as_deref(), Some("0.14.0"));
}
#[test]
fn default_path_resolves_or_returns_none_gracefully() {
let _ = default_state_file();
let _ = default_state_dir();
}
#[test]
fn slot_for_db_is_deterministic() {
let p = Path::new("/tmp/some-cartog.db");
let a = slot_for_db("serve", p);
let b = slot_for_db("serve", p);
assert_eq!(a, b);
}
#[test]
fn slot_for_db_differs_per_prefix() {
let p = Path::new("/tmp/cartog.db");
assert_ne!(slot_for_db("serve", p), slot_for_db("watch", p));
}
#[test]
fn slot_for_db_differs_per_path() {
assert_ne!(
slot_for_db("serve", Path::new("/a/cartog.db")),
slot_for_db("serve", Path::new("/b/cartog.db"))
);
}
#[test]
fn slot_for_db_is_filesystem_safe() {
let slot = slot_for_db("serve", Path::new("/path/with spaces & specials/cartog.db"));
assert!(slot.starts_with("serve-"));
let hex = &slot["serve-".len()..];
assert_eq!(hex.len(), 16);
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn slot_for_db_stable_across_db_creation() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join("cartog.db");
let before = slot_for_db("serve", &db_path);
std::fs::write(&db_path, b"").unwrap();
let after = slot_for_db("serve", &db_path);
assert_eq!(
before, after,
"slot must be stable across DB creation (parent canonicalize path)"
);
}
#[test]
fn slot_for_db_normalizes_symlinked_parents() {
let dir = tempfile::TempDir::new().unwrap();
let real = dir.path().join("data");
std::fs::create_dir(&real).unwrap();
let link = dir.path().join("link");
#[cfg(unix)]
std::os::unix::fs::symlink(&real, &link).unwrap();
#[cfg(not(unix))]
{
let _ = link;
return;
}
let via_real = slot_for_db("serve", &real.join("cartog.db"));
let via_link = slot_for_db("serve", &link.join("cartog.db"));
assert_eq!(
via_real, via_link,
"logically-equivalent paths must share the slot"
);
}
#[test]
#[serial]
fn slot_for_db_normalizes_relative_when_parent_exists() {
let dir = tempfile::TempDir::new().unwrap();
let abs = dir.path().join("cartog.db");
let prev = std::env::current_dir().ok();
std::env::set_current_dir(dir.path()).unwrap();
let via_rel = slot_for_db("serve", Path::new("./cartog.db"));
let via_abs = slot_for_db("serve", &abs);
if let Some(p) = prev {
let _ = std::env::set_current_dir(p);
}
assert_eq!(
via_rel, via_abs,
"relative and absolute paths to the same DB must share the slot"
);
}
#[test]
fn slot_for_db_resolves_symlinked_db_leaf() {
let dir = tempfile::TempDir::new().unwrap();
let real_dir = dir.path().join("storage");
std::fs::create_dir(&real_dir).unwrap();
let real_db = real_dir.join("real.db");
std::fs::write(&real_db, b"").unwrap();
let link_dir = dir.path().join("proj");
std::fs::create_dir(&link_dir).unwrap();
let link_db = link_dir.join("db.sqlite");
#[cfg(unix)]
std::os::unix::fs::symlink(&real_db, &link_db).unwrap();
#[cfg(not(unix))]
{
let _ = link_db;
return;
}
let via_real = slot_for_db("serve", &real_db);
let via_link = slot_for_db("serve", &link_db);
assert_eq!(
via_real, via_link,
"symlinked DB leaf must produce the same slot as the real target"
);
}
#[test]
#[serial]
fn slot_for_db_bare_relative_anchors_on_cwd() {
let dir = tempfile::TempDir::new().unwrap();
let prev = std::env::current_dir().ok();
std::env::set_current_dir(dir.path()).unwrap();
let bare = slot_for_db("serve", Path::new("db.sqlite"));
let dotted = slot_for_db("serve", Path::new("./db.sqlite"));
let absolute = slot_for_db("serve", &dir.path().join("db.sqlite"));
if let Some(p) = prev {
let _ = std::env::set_current_dir(p);
}
assert_eq!(
bare, dotted,
"bare relative and './' relative must produce the same slot"
);
assert_eq!(
bare, absolute,
"bare relative must anchor on cwd and match the absolute equivalent"
);
}
#[test]
fn slot_for_db_walks_up_when_parent_missing() {
let dir = tempfile::TempDir::new().unwrap();
let a = dir.path().join(".cartog").join("db.sqlite");
let b = dir.path().join(".").join(".cartog").join("db.sqlite");
let slot_a = slot_for_db("serve", &a);
let slot_b = slot_for_db("serve", &b);
assert_eq!(
slot_a, slot_b,
"equivalent paths with missing parent must produce the same slot"
);
}
#[test]
fn slot_for_db_step3_normalizes_curdir_when_no_ancestor_exists() {
let bare = slot_for_db(
"serve",
Path::new("/nonexistent-cartog-root/proj/db.sqlite"),
);
let dotted = slot_for_db(
"serve",
Path::new("/nonexistent-cartog-root/./proj/db.sqlite"),
);
assert_eq!(
bare, dotted,
"step-3 fallback must normalize CurDir to keep equivalent missing paths consistent"
);
}
#[test]
fn slot_for_db_stable_across_parent_creation() {
let dir = tempfile::TempDir::new().unwrap();
let db_path = dir.path().join(".cartog").join("db.sqlite");
let before = slot_for_db("serve", &db_path);
std::fs::create_dir_all(db_path.parent().unwrap()).unwrap();
std::fs::write(&db_path, b"").unwrap();
let after = slot_for_db("serve", &db_path);
assert_eq!(
before, after,
"slot must be stable across parent-dir + DB-file creation"
);
}
}