use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use praca::{Praca, PracaSnapshot};
use tracing::{debug, warn};
#[derive(Clone)]
pub struct PracaStore {
inner: Arc<PracaInner>,
}
struct PracaInner {
praca: Mutex<Praca>,
path: PathBuf,
}
impl PracaStore {
#[must_use]
pub fn open(path: PathBuf) -> Self {
let praca = match load_snapshot(&path) {
Ok(Some(snap)) => {
debug!(
path = %path.display(),
bindings = snap.binding.len(),
sessions = snap.index.len(),
"praca store: loaded snapshot"
);
Praca::from_snapshot(snap)
}
Ok(None) => {
debug!(path = %path.display(), "praca store: no snapshot yet (fresh)");
Praca::new()
}
Err(e) => {
warn!(
path = %path.display(),
error = %e,
"praca store: snapshot load failed; starting empty"
);
Praca::new()
}
};
Self {
inner: Arc::new(PracaInner {
praca: Mutex::new(praca),
path,
}),
}
}
#[must_use]
pub fn open_default() -> Self {
Self::open(default_praca_path())
}
#[must_use]
pub fn path(&self) -> &Path {
&self.inner.path
}
pub fn mutate<R>(&self, f: impl FnOnce(&mut Praca) -> R) -> R {
let mut guard = self.inner.praca.lock().expect("praca store lock poisoned");
let out = f(&mut guard);
let snap = guard.to_snapshot();
drop(guard);
if let Err(e) = persist_snapshot(&self.inner.path, &snap) {
warn!(
path = %self.inner.path.display(),
error = %e,
"praca store: persist failed; binding may not survive restart"
);
}
out
}
pub fn with<R>(&self, f: impl FnOnce(&Praca) -> R) -> R {
let guard = self.inner.praca.lock().expect("praca store lock poisoned");
f(&guard)
}
}
#[must_use]
pub fn default_praca_path() -> PathBuf {
state_dir().join("tear").join("praca.json")
}
fn state_dir() -> PathBuf {
if let Ok(explicit) = std::env::var("TEAR_STATE_DIR") {
return PathBuf::from(explicit);
}
if let Ok(xdg) = std::env::var("XDG_STATE_HOME")
&& !xdg.is_empty()
{
return PathBuf::from(xdg);
}
if let Ok(home) = std::env::var("HOME") {
let mut p = PathBuf::from(home);
p.push(".local");
p.push("state");
return p;
}
PathBuf::from(".")
}
fn load_snapshot(path: &Path) -> std::io::Result<Option<PracaSnapshot>> {
if !path.exists() {
return Ok(None);
}
let text = std::fs::read_to_string(path)?;
let snap: PracaSnapshot = serde_json::from_str(&text)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(Some(snap))
}
fn persist_snapshot(path: &Path, snap: &PracaSnapshot) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(snap)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = tmp_path(path);
std::fs::write(&tmp, json.as_bytes())?;
std::fs::rename(&tmp, path)?;
Ok(())
}
fn tmp_path(path: &Path) -> PathBuf {
let pid = std::process::id();
let mut name = path
.file_name()
.map(std::ffi::OsStr::to_os_string)
.unwrap_or_default();
name.push(format!(".tmp.{pid}"));
path.with_file_name(name)
}
#[cfg(test)]
mod tests {
use super::*;
use praca::SessionRecord;
use std::path::PathBuf;
use tear_types::SessionId;
fn sid(s: &str) -> SessionId {
SessionId::from_seed(s)
}
fn temp_dir(tag: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let pid = std::process::id();
let nonce: u128 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
p.push(format!("tear-praca-{tag}-{pid}-{nonce}"));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn open_missing_file_starts_empty() {
let dir = temp_dir("missing");
let path = dir.join("praca.json");
let store = PracaStore::open(path.clone());
assert!(!path.exists(), "open must not create the file eagerly");
store.with(|p| {
assert!(p.index.is_empty());
assert!(p.binding.is_empty());
});
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn mutate_persists_and_reload_survives_round_trip() {
let dir = temp_dir("round-trip");
let path = dir.join("praca.json");
{
let store = PracaStore::open(path.clone());
store.mutate(|p| {
let rec = SessionRecord::for_project(
sid("tide"),
PathBuf::from("/code/pleme-io/mado"),
praca::SessionNameStyle::Emoji,
1_700,
);
p.index.upsert(rec);
p.binding
.bind(PathBuf::from("/code/pleme-io/mado"), sid("tide"));
p.record_visit(sid("tide"), 1_999);
});
assert!(path.exists(), "mutate must persist the file");
}
let reloaded = PracaStore::open(path.clone());
reloaded.with(|p| {
assert_eq!(
p.binding.lookup(Path::new("/code/pleme-io/mado")),
Some(sid("tide")),
"binding survived the restart"
);
let r = p.index.get(sid("tide")).expect("record survived");
assert_eq!(r.last_seen, 1_999, "frecency last_seen survived");
assert_eq!(r.visits, 2, "frecency visits survived (1 init + 1 visit)");
});
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn second_session_resolves_persisted_binding() {
let dir = temp_dir("second-session");
let path = dir.join("praca.json");
let root = PathBuf::from("/code/pleme-io/tear");
{
let store = PracaStore::open(path.clone());
store.mutate(|p| {
let rec = SessionRecord::for_project(
sid("frost"),
root.clone(),
praca::SessionNameStyle::Emoji,
100,
);
p.index.upsert(rec);
p.binding.bind(root.clone(), sid("frost"));
});
}
let store2 = PracaStore::open(path.clone());
let decision = store2.with(|p| {
praca::decide_with_root(
p.policy,
None,
&root,
&p.binding,
&p.index,
p.name_style,
)
});
assert_eq!(
decision,
praca::AttachDecision::SwitchTo(sid("frost")),
"a persisted binding auto-attaches in a later session"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn atomic_write_leaves_no_temp_and_valid_json() {
let dir = temp_dir("atomic");
let path = dir.join("praca.json");
let store = PracaStore::open(path.clone());
store.mutate(|p| {
p.binding.bind(PathBuf::from("/x"), sid("x"));
});
assert!(path.exists());
let tmp = tmp_path(&path);
assert!(!tmp.exists(), "temp file must be renamed away, not left behind");
let text = std::fs::read_to_string(&path).unwrap();
let parsed: PracaSnapshot = serde_json::from_str(&text).unwrap();
assert_eq!(parsed.binding.lookup(Path::new("/x")), Some(sid("x")));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn corrupt_file_starts_empty_not_wedged() {
let dir = temp_dir("corrupt");
let path = dir.join("praca.json");
std::fs::write(&path, b"{not valid json").unwrap();
let store = PracaStore::open(path.clone());
store.with(|p| assert!(p.binding.is_empty()));
store.mutate(|p| {
p.binding.bind(PathBuf::from("/recover"), sid("r"));
});
let reloaded = PracaStore::open(path.clone());
reloaded.with(|p| {
assert_eq!(
p.binding.lookup(Path::new("/recover")),
Some(sid("r")),
"store recovers from a corrupt file on the next write"
);
});
let _ = std::fs::remove_dir_all(&dir);
}
}