Skip to main content

dodot_lib/prompts/
mod.rs

1//! Persistent registry of dismissed prompts.
2//!
3//! A content-agnostic key/value store for "have I shown the user X yet?"
4//! state. Callers pass opaque string keys; the registry tracks which
5//! keys have been dismissed and when. Reset clears one or all keys so
6//! the next caller-side check fires the prompt again.
7//!
8//! Used as the foundation for one-time onboarding prompts, install
9//! offers, and any other nudge that should not repeat after the user
10//! has answered it. The registry itself does NOT render prompts, decide
11//! UX, or know what each key means — that is the caller's job.
12//!
13//! See `docs/proposals/plists.lex` §5.3 for the first user (the plist
14//! filter install offer); future callers slot in by picking their own
15//! key and updating the catalog in [`catalog`] so `dodot prompts list`
16//! can describe them.
17//!
18//! Storage: JSON at `<data_dir>/prompts.json` (durable, alongside other
19//! persistent state). Schema is versioned for forward compatibility.
20
21use 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/// One row in the registry: when the user dismissed this prompt.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct PromptRecord {
37    /// Wall-clock unix timestamp (seconds) of the dismissal.
38    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/// Persistent dismissed-prompt registry.
58///
59/// `load` populates from disk (returns an empty registry if the file
60/// does not exist); mutations are in-memory until `save` is called.
61#[derive(Debug, Clone)]
62pub struct PromptRegistry {
63    path: PathBuf,
64    file: PromptFile,
65}
66
67impl PromptRegistry {
68    /// Load the registry from `path`. A missing file is treated as an
69    /// empty registry — first-run users have nothing to read.
70    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    /// Persist the registry to its backing path. Creates parent dirs
96    /// as needed.
97    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    /// Backing file path (for diagnostic messages and `dodot prompts list`).
108    pub fn path(&self) -> &Path {
109        &self.path
110    }
111
112    /// True if the prompt with this key has been dismissed.
113    pub fn is_dismissed(&self, key: &str) -> bool {
114        self.file.prompts.contains_key(key)
115    }
116
117    /// Record that the user dismissed this prompt. Idempotent — calling
118    /// twice updates `dismissed_at` to the latest call.
119    pub fn dismiss(&mut self, key: &str) {
120        self.dismiss_at(key, now_secs_unix())
121    }
122
123    /// Test-friendly sibling of [`dismiss`] that takes an explicit
124    /// timestamp instead of reading the wall clock.
125    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    /// Clear a single dismissal so the prompt fires again next time.
132    /// Returns `true` if the key was present.
133    pub fn reset(&mut self, key: &str) -> bool {
134        self.file.prompts.remove(key).is_some()
135    }
136
137    /// Clear every dismissal. Returns the count cleared.
138    pub fn reset_all(&mut self) -> usize {
139        let n = self.file.prompts.len();
140        self.file.prompts.clear();
141        n
142    }
143
144    /// Snapshot of dismissed prompts, sorted by key. Suitable for
145    /// rendering by `dodot prompts list`. Allocates; if you only need
146    /// per-key lookups, prefer [`dismissed_at`](Self::dismissed_at).
147    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    /// O(log n) lookup of a single prompt's dismissal timestamp.
156    /// Returns `None` if the prompt is currently active. Pair with
157    /// [`is_dismissed`](Self::is_dismissed) when you only need the
158    /// boolean — this returns the timestamp too for UIs that show
159    /// "dismissed at …".
160    pub fn dismissed_at(&self, key: &str) -> Option<u64> {
161        self.file.prompts.get(key).map(|r| r.dismissed_at)
162    }
163}
164
165/// Wall-clock unix timestamp helper used by [`PromptRegistry::dismiss`].
166/// Tests should call [`PromptRegistry::dismiss_at`] with a fixed value.
167pub 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        // BTreeMap ordering — keys are sorted.
225        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")); // already gone
236        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); // already empty
249    }
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}