use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
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,
}
#[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))
}
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 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,
};
original.save_to(&path).expect("save");
let loaded = State::load_from(&path);
assert_eq!(loaded, original);
}
#[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();
}
}