1use std::collections::BTreeMap;
22use std::path::{Path, PathBuf};
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use serde::{Deserialize, Serialize};
26
27use crate::fs::Fs;
28use crate::{DodotError, Result};
29
30pub mod catalog;
31
32const SCHEMA_VERSION: u32 = 1;
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct PromptRecord {
37 pub dismissed_at: u64,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42struct PromptFile {
43 version: u32,
44 #[serde(default)]
45 prompts: BTreeMap<String, PromptRecord>,
46}
47
48impl Default for PromptFile {
49 fn default() -> Self {
50 Self {
51 version: SCHEMA_VERSION,
52 prompts: BTreeMap::new(),
53 }
54 }
55}
56
57#[derive(Debug, Clone)]
62pub struct PromptRegistry {
63 path: PathBuf,
64 file: PromptFile,
65}
66
67impl PromptRegistry {
68 pub fn load(fs: &dyn Fs, path: PathBuf) -> Result<Self> {
71 if !fs.exists(&path) {
72 return Ok(Self {
73 path,
74 file: PromptFile::default(),
75 });
76 }
77 let raw = fs.read_to_string(&path)?;
78 let file: PromptFile = serde_json::from_str(&raw).map_err(|e| {
79 DodotError::Other(format!(
80 "failed to parse prompts registry at {}: {e}",
81 path.display()
82 ))
83 })?;
84 if file.version != SCHEMA_VERSION {
85 return Err(DodotError::Other(format!(
86 "prompts registry at {} has unsupported schema version {} (expected {})",
87 path.display(),
88 file.version,
89 SCHEMA_VERSION
90 )));
91 }
92 Ok(Self { path, file })
93 }
94
95 pub fn save(&self, fs: &dyn Fs) -> Result<()> {
98 if let Some(parent) = self.path.parent() {
99 fs.mkdir_all(parent)?;
100 }
101 let body = serde_json::to_string_pretty(&self.file)
102 .map_err(|e| DodotError::Other(format!("failed to serialise prompts: {e}")))?;
103 fs.write_file(&self.path, body.as_bytes())?;
104 Ok(())
105 }
106
107 pub fn path(&self) -> &Path {
109 &self.path
110 }
111
112 pub fn is_dismissed(&self, key: &str) -> bool {
114 self.file.prompts.contains_key(key)
115 }
116
117 pub fn dismiss(&mut self, key: &str) {
120 self.dismiss_at(key, now_secs_unix())
121 }
122
123 pub fn dismiss_at(&mut self, key: &str, dismissed_at: u64) {
126 self.file
127 .prompts
128 .insert(key.to_string(), PromptRecord { dismissed_at });
129 }
130
131 pub fn reset(&mut self, key: &str) -> bool {
134 self.file.prompts.remove(key).is_some()
135 }
136
137 pub fn reset_all(&mut self) -> usize {
139 let n = self.file.prompts.len();
140 self.file.prompts.clear();
141 n
142 }
143
144 pub fn dismissed(&self) -> Vec<(&str, &PromptRecord)> {
148 self.file
149 .prompts
150 .iter()
151 .map(|(k, v)| (k.as_str(), v))
152 .collect()
153 }
154
155 pub fn dismissed_at(&self, key: &str) -> Option<u64> {
161 self.file.prompts.get(key).map(|r| r.dismissed_at)
162 }
163}
164
165pub fn now_secs_unix() -> u64 {
168 SystemTime::now()
169 .duration_since(UNIX_EPOCH)
170 .map(|d| d.as_secs())
171 .unwrap_or(0)
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177 use crate::paths::Pather;
178 use crate::testing::TempEnvironment;
179
180 fn registry(env: &TempEnvironment) -> PromptRegistry {
181 let path = env.paths.prompts_path();
182 PromptRegistry::load(env.fs.as_ref(), path).expect("load")
183 }
184
185 #[test]
186 fn load_missing_file_returns_empty() {
187 let env = TempEnvironment::builder().build();
188 let r = registry(&env);
189 assert!(r.dismissed().is_empty());
190 assert!(!r.is_dismissed("anything"));
191 }
192
193 #[test]
194 fn dismiss_then_query() {
195 let env = TempEnvironment::builder().build();
196 let mut r = registry(&env);
197 r.dismiss_at("plist.install_filters", 1714557600);
198 assert!(r.is_dismissed("plist.install_filters"));
199 assert!(!r.is_dismissed("something.else"));
200 }
201
202 #[test]
203 fn dismiss_is_idempotent_and_updates_timestamp() {
204 let env = TempEnvironment::builder().build();
205 let mut r = registry(&env);
206 r.dismiss_at("k", 100);
207 r.dismiss_at("k", 200);
208 assert_eq!(r.dismissed().len(), 1);
209 assert_eq!(r.dismissed()[0].1.dismissed_at, 200);
210 }
211
212 #[test]
213 fn save_and_reload_roundtrip() {
214 let env = TempEnvironment::builder().build();
215 {
216 let mut r = registry(&env);
217 r.dismiss_at("a", 100);
218 r.dismiss_at("b", 200);
219 r.save(env.fs.as_ref()).expect("save");
220 }
221 let r = registry(&env);
222 let dismissed = r.dismissed();
223 assert_eq!(dismissed.len(), 2);
224 assert_eq!(dismissed[0].0, "a");
226 assert_eq!(dismissed[1].0, "b");
227 }
228
229 #[test]
230 fn reset_one_returns_whether_present() {
231 let env = TempEnvironment::builder().build();
232 let mut r = registry(&env);
233 r.dismiss_at("a", 100);
234 assert!(r.reset("a"));
235 assert!(!r.reset("a")); assert!(!r.reset("never-set"));
237 }
238
239 #[test]
240 fn reset_all_returns_count_cleared() {
241 let env = TempEnvironment::builder().build();
242 let mut r = registry(&env);
243 r.dismiss_at("a", 1);
244 r.dismiss_at("b", 2);
245 r.dismiss_at("c", 3);
246 assert_eq!(r.reset_all(), 3);
247 assert!(r.dismissed().is_empty());
248 assert_eq!(r.reset_all(), 0); }
250
251 #[test]
252 fn corrupted_file_returns_error() {
253 let env = TempEnvironment::builder().build();
254 let path = env.paths.prompts_path();
255 env.fs.as_ref().mkdir_all(path.parent().unwrap()).unwrap();
256 env.fs.as_ref().write_file(&path, b"{not json").unwrap();
257 let err = PromptRegistry::load(env.fs.as_ref(), path).unwrap_err();
258 assert!(
259 format!("{err}").contains("failed to parse"),
260 "expected parse error, got: {err}"
261 );
262 }
263
264 #[test]
265 fn unsupported_schema_version_returns_error() {
266 let env = TempEnvironment::builder().build();
267 let path = env.paths.prompts_path();
268 env.fs.as_ref().mkdir_all(path.parent().unwrap()).unwrap();
269 env.fs
270 .as_ref()
271 .write_file(&path, br#"{"version": 999, "prompts": {}}"#)
272 .unwrap();
273 let err = PromptRegistry::load(env.fs.as_ref(), path).unwrap_err();
274 assert!(
275 format!("{err}").contains("unsupported schema version"),
276 "expected schema error, got: {err}"
277 );
278 }
279}