use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::fs::Fs;
use crate::{DodotError, Result};
pub mod catalog;
const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PromptRecord {
pub dismissed_at: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PromptFile {
version: u32,
#[serde(default)]
prompts: BTreeMap<String, PromptRecord>,
}
impl Default for PromptFile {
fn default() -> Self {
Self {
version: SCHEMA_VERSION,
prompts: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct PromptRegistry {
path: PathBuf,
file: PromptFile,
}
impl PromptRegistry {
pub fn load(fs: &dyn Fs, path: PathBuf) -> Result<Self> {
if !fs.exists(&path) {
return Ok(Self {
path,
file: PromptFile::default(),
});
}
let raw = fs.read_to_string(&path)?;
let file: PromptFile = serde_json::from_str(&raw).map_err(|e| {
DodotError::Other(format!(
"failed to parse prompts registry at {}: {e}",
path.display()
))
})?;
if file.version != SCHEMA_VERSION {
return Err(DodotError::Other(format!(
"prompts registry at {} has unsupported schema version {} (expected {})",
path.display(),
file.version,
SCHEMA_VERSION
)));
}
Ok(Self { path, file })
}
pub fn save(&self, fs: &dyn Fs) -> Result<()> {
if let Some(parent) = self.path.parent() {
fs.mkdir_all(parent)?;
}
let body = serde_json::to_string_pretty(&self.file)
.map_err(|e| DodotError::Other(format!("failed to serialise prompts: {e}")))?;
fs.write_file(&self.path, body.as_bytes())?;
Ok(())
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn is_dismissed(&self, key: &str) -> bool {
self.file.prompts.contains_key(key)
}
pub fn dismiss(&mut self, key: &str) {
self.dismiss_at(key, now_secs_unix())
}
pub fn dismiss_at(&mut self, key: &str, dismissed_at: u64) {
self.file
.prompts
.insert(key.to_string(), PromptRecord { dismissed_at });
}
pub fn reset(&mut self, key: &str) -> bool {
self.file.prompts.remove(key).is_some()
}
pub fn reset_all(&mut self) -> usize {
let n = self.file.prompts.len();
self.file.prompts.clear();
n
}
pub fn dismissed(&self) -> Vec<(&str, &PromptRecord)> {
self.file
.prompts
.iter()
.map(|(k, v)| (k.as_str(), v))
.collect()
}
pub fn dismissed_at(&self, key: &str) -> Option<u64> {
self.file.prompts.get(key).map(|r| r.dismissed_at)
}
}
pub fn now_secs_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::Pather;
use crate::testing::TempEnvironment;
fn registry(env: &TempEnvironment) -> PromptRegistry {
let path = env.paths.prompts_path();
PromptRegistry::load(env.fs.as_ref(), path).expect("load")
}
#[test]
fn load_missing_file_returns_empty() {
let env = TempEnvironment::builder().build();
let r = registry(&env);
assert!(r.dismissed().is_empty());
assert!(!r.is_dismissed("anything"));
}
#[test]
fn dismiss_then_query() {
let env = TempEnvironment::builder().build();
let mut r = registry(&env);
r.dismiss_at("plist.install_filters", 1714557600);
assert!(r.is_dismissed("plist.install_filters"));
assert!(!r.is_dismissed("something.else"));
}
#[test]
fn dismiss_is_idempotent_and_updates_timestamp() {
let env = TempEnvironment::builder().build();
let mut r = registry(&env);
r.dismiss_at("k", 100);
r.dismiss_at("k", 200);
assert_eq!(r.dismissed().len(), 1);
assert_eq!(r.dismissed()[0].1.dismissed_at, 200);
}
#[test]
fn save_and_reload_roundtrip() {
let env = TempEnvironment::builder().build();
{
let mut r = registry(&env);
r.dismiss_at("a", 100);
r.dismiss_at("b", 200);
r.save(env.fs.as_ref()).expect("save");
}
let r = registry(&env);
let dismissed = r.dismissed();
assert_eq!(dismissed.len(), 2);
assert_eq!(dismissed[0].0, "a");
assert_eq!(dismissed[1].0, "b");
}
#[test]
fn reset_one_returns_whether_present() {
let env = TempEnvironment::builder().build();
let mut r = registry(&env);
r.dismiss_at("a", 100);
assert!(r.reset("a"));
assert!(!r.reset("a")); assert!(!r.reset("never-set"));
}
#[test]
fn reset_all_returns_count_cleared() {
let env = TempEnvironment::builder().build();
let mut r = registry(&env);
r.dismiss_at("a", 1);
r.dismiss_at("b", 2);
r.dismiss_at("c", 3);
assert_eq!(r.reset_all(), 3);
assert!(r.dismissed().is_empty());
assert_eq!(r.reset_all(), 0); }
#[test]
fn corrupted_file_returns_error() {
let env = TempEnvironment::builder().build();
let path = env.paths.prompts_path();
env.fs.as_ref().mkdir_all(path.parent().unwrap()).unwrap();
env.fs.as_ref().write_file(&path, b"{not json").unwrap();
let err = PromptRegistry::load(env.fs.as_ref(), path).unwrap_err();
assert!(
format!("{err}").contains("failed to parse"),
"expected parse error, got: {err}"
);
}
#[test]
fn unsupported_schema_version_returns_error() {
let env = TempEnvironment::builder().build();
let path = env.paths.prompts_path();
env.fs.as_ref().mkdir_all(path.parent().unwrap()).unwrap();
env.fs
.as_ref()
.write_file(&path, br#"{"version": 999, "prompts": {}}"#)
.unwrap();
let err = PromptRegistry::load(env.fs.as_ref(), path).unwrap_err();
assert!(
format!("{err}").contains("unsupported schema version"),
"expected schema error, got: {err}"
);
}
}