use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::config::Config;
use super::persistence::{config_reads_allowed, config_writes_allowed};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct State {
pub seen_terminal_fingerprints: Vec<String>,
pub last_update_check: u64,
pub update_notified_for: String,
pub last_version_seen: String,
pub seen_daily_tips: Vec<u32>,
pub last_tip_shown: u64,
pub cursors: Vec<RememberedCursor>,
}
pub const MAX_REMEMBERED_CURSORS: usize = 50;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RememberedCursor {
pub path: String,
pub offset: usize,
}
pub fn cursor_key(path: &Path) -> String {
std::fs::canonicalize(path)
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.into_owned()
}
impl State {
pub fn cursor_for(&self, key: &str) -> Option<usize> {
self.cursors
.iter()
.find(|c| c.path == key)
.map(|c| c.offset)
}
pub fn remember_cursor(&mut self, key: String, offset: usize) {
self.cursors.retain(|c| c.path != key);
self.cursors.push(RememberedCursor { path: key, offset });
let excess = self.cursors.len().saturating_sub(MAX_REMEMBERED_CURSORS);
self.cursors.drain(..excess);
}
pub fn path() -> Option<PathBuf> {
Config::data_dir().map(|d| d.join("state.toml"))
}
pub fn load() -> State {
if !config_reads_allowed() {
return State::default();
}
let Some(path) = Self::path() else {
return State::default();
};
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return State::default(),
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "failed to read state.toml");
return State::default();
}
};
match toml::from_str(&raw) {
Ok(state) => state,
Err(e) => {
tracing::warn!(error = %e, path = %path.display(), "failed to parse state.toml");
State::default()
}
}
}
pub fn save(&self) -> Result<()> {
if !config_writes_allowed() {
return Ok(());
}
let path = Self::path()
.context("Could not determine data directory (missing XDG_DATA_HOME/HOME)")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create data directory: {}", parent.display())
})?;
}
let output = toml::to_string_pretty(self).context("Failed to serialize state to TOML")?;
std::fs::write(&path, output)
.with_context(|| format!("Failed to write state file: {}", path.display()))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn state_defaults_and_round_trips() {
let state = State::default();
assert!(state.seen_terminal_fingerprints.is_empty());
assert_eq!(state.last_update_check, 0);
assert_eq!(state.update_notified_for, "");
assert_eq!(state.last_version_seen, "");
assert!(state.seen_daily_tips.is_empty());
assert_eq!(state.last_tip_shown, 0);
assert!(state.cursors.is_empty());
let state = State {
seen_terminal_fingerprints: vec![
"WezTerm|xterm-256color||truecolor|kitty|mouse=true|kbd=true|unicode=true".into(),
],
last_update_check: 1_755_500_000,
update_notified_for: "v0.2.0".to_owned(),
last_version_seen: "0.1.9".to_owned(),
seen_daily_tips: vec![1, 2],
last_tip_shown: 1_755_600_000,
cursors: vec![RememberedCursor {
path: "/tmp/notes.md".to_owned(),
offset: 42,
}],
};
let serialized = toml::to_string_pretty(&state).expect("serialize");
let deserialized: State = toml::from_str(&serialized).expect("deserialize");
assert_eq!(
deserialized.seen_terminal_fingerprints,
state.seen_terminal_fingerprints
);
assert_eq!(deserialized.last_update_check, 1_755_500_000);
assert_eq!(deserialized.update_notified_for, "v0.2.0");
assert_eq!(deserialized.last_version_seen, "0.1.9");
assert_eq!(deserialized.seen_daily_tips, vec![1, 2]);
assert_eq!(deserialized.last_tip_shown, 1_755_600_000);
assert_eq!(deserialized.cursors, state.cursors);
assert_eq!(deserialized.cursor_for("/tmp/notes.md"), Some(42));
}
#[test]
fn partial_state_falls_back_to_defaults() {
let state: State = toml::from_str("last_update_check = 42\n").expect("deserialize");
assert_eq!(state.last_update_check, 42);
assert!(state.seen_terminal_fingerprints.is_empty());
assert_eq!(state.last_version_seen, "");
}
#[test]
fn path_lives_in_the_data_dir() {
if let Some(path) = State::path() {
assert!(path.ends_with("edamame/state.toml"), "{path:?}");
}
}
#[test]
fn save_and_load_are_no_ops_under_no_config() {
let _lock = crate::test_env::env_lock();
let state = State {
last_version_seen: "9.9.9".to_owned(),
..State::default()
};
let _suppressed = crate::config::persistence::SuppressGuard::new();
assert!(state.save().is_ok(), "a suppressed save is not a failure");
assert_eq!(State::load().last_version_seen, "", "load reads nothing");
}
#[cfg(target_os = "linux")]
#[test]
fn save_then_load_round_trips_through_disk() {
let _lock = crate::test_env::env_lock();
let dir = tempfile::tempdir().unwrap();
let _xdg = crate::test_env::EnvGuard::set("XDG_DATA_HOME", dir.path());
let state = State {
last_version_seen: "9.9.9".to_owned(),
..State::default()
};
state.save().expect("save ok");
assert!(dir.path().join("edamame/state.toml").exists());
assert_eq!(State::load().last_version_seen, "9.9.9");
}
#[test]
fn remembering_a_file_again_moves_it_to_the_end() {
let mut state = State::default();
state.remember_cursor("a".to_owned(), 1);
state.remember_cursor("b".to_owned(), 2);
state.remember_cursor("a".to_owned(), 3);
assert_eq!(state.cursor_for("a"), Some(3), "the latest offset wins");
assert_eq!(state.cursor_for("b"), Some(2));
assert_eq!(
state
.cursors
.iter()
.map(|c| c.path.as_str())
.collect::<Vec<_>>(),
vec!["b", "a"],
"the entry just written sits last, where the cap evicts from the front"
);
}
#[test]
fn remembering_evicts_the_oldest_past_the_cap() {
let mut state = State::default();
for i in 0..MAX_REMEMBERED_CURSORS + 5 {
state.remember_cursor(format!("file{i}"), i);
}
assert_eq!(state.cursors.len(), MAX_REMEMBERED_CURSORS);
assert_eq!(state.cursor_for("file4"), None, "the oldest five are gone");
assert_eq!(state.cursor_for("file5"), Some(5));
}
#[test]
fn cursor_key_canonicalizes_and_falls_back() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.md");
std::fs::write(&path, "x\n").unwrap();
let roundabout = dir.path().join(".").join("notes.md");
assert_eq!(
cursor_key(&roundabout),
cursor_key(&path),
"a roundabout spelling is the same file"
);
let missing = dir.path().join("nope.md");
assert!(
cursor_key(&missing).ends_with("nope.md"),
"an absent file still keys on its path instead of panicking"
);
}
}