Skip to main content

fallow_cli/
walkthrough_state.rs

1//! Local, account-free viewed-state for `fallow review --walkthrough`.
2//!
3//! Per-file "I've looked at this" marks live in a small JSON ledger inside the
4//! resolved cache dir (`.fallow/walkthrough-state.json`, already gitignored).
5//! The ledger is purely local: no account, no network, human/git-readable.
6//!
7//! Staleness is keyed on the guide's `graph_snapshot_hash`. A mark is honored
8//! ONLY when the stored hash matches the current guide hash, so a tree that
9//! moved silently un-views every file for that render without deleting the marks
10//! (a no-op carry-forward keeps them if the user reverts). Rendering is
11//! read-only and idempotent; marks are written ONLY on an explicit
12//! `--mark-viewed` mutation, never as a side effect of a render.
13
14use std::collections::BTreeMap;
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18
19/// Current ledger format version. Bumped only on a breaking shape change; a
20/// reader that sees an unknown version treats the file as empty (safe default).
21const VIEWED_STATE_VERSION: u32 = 1;
22
23/// Stable schema discriminator, so a stray file under the cache dir is not
24/// mistaken for a viewed-state ledger.
25const VIEWED_STATE_SCHEMA: &str = "walkthrough-viewed-marks";
26
27/// One viewed entry: when the file was marked viewed (RFC 3339-ish UTC).
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ViewedEntry {
30    /// UTC timestamp the mark was recorded.
31    pub viewed_at: String,
32}
33
34/// The on-disk viewed-state ledger.
35///
36/// `entries` is keyed by the per-file VIEW key (the file path from the guide's
37/// `direction.order`). `graph_snapshot_hash` pins the marks to the guide they
38/// were recorded against; a mismatch on read means the tree moved and every
39/// entry is treated as not-viewed for that render (see [`ViewedState::is_viewed`]).
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ViewedState {
42    /// Ledger format version.
43    pub version: u32,
44    /// Schema discriminator.
45    pub schema: String,
46    /// The guide hash the marks were recorded against.
47    #[serde(default)]
48    pub graph_snapshot_hash: String,
49    /// Per-file viewed marks, keyed by the file path.
50    #[serde(default)]
51    pub entries: BTreeMap<String, ViewedEntry>,
52}
53
54impl Default for ViewedState {
55    fn default() -> Self {
56        Self {
57            version: VIEWED_STATE_VERSION,
58            schema: VIEWED_STATE_SCHEMA.to_string(),
59            graph_snapshot_hash: String::new(),
60            entries: BTreeMap::new(),
61        }
62    }
63}
64
65impl ViewedState {
66    /// Whether `file` is currently viewed: it must have a recorded mark AND the
67    /// ledger's stored hash must match the guide's `current_hash`. A stale hash
68    /// (the tree moved) makes every file read as not-viewed, the safe direction.
69    #[must_use]
70    pub fn is_viewed(&self, file: &str, current_hash: &str) -> bool {
71        if self.graph_snapshot_hash.is_empty() || self.graph_snapshot_hash != current_hash {
72            return false;
73        }
74        self.entries.contains_key(file)
75    }
76}
77
78/// Load the viewed-state ledger from `cache_dir`, returning an empty default
79/// when the file is missing, unreadable, or not valid JSON.
80///
81/// A missing ledger is the common first-run case, and a garbled one must never
82/// hard-error a render, so both collapse to the empty default rather than a
83/// caller-visible error. A version the reader does not understand is also
84/// treated as empty (forward-compat: a future writer's shape never crashes an
85/// older render).
86#[must_use]
87pub fn load_viewed_state(cache_dir: &Path) -> ViewedState {
88    let path = fallow_config::walkthrough_state_path(cache_dir);
89    let Ok(contents) = std::fs::read_to_string(&path) else {
90        return ViewedState::default();
91    };
92    let Ok(state) = serde_json::from_str::<ViewedState>(&contents) else {
93        return ViewedState::default();
94    };
95    if state.version != VIEWED_STATE_VERSION || state.schema != VIEWED_STATE_SCHEMA {
96        return ViewedState::default();
97    }
98    state
99}
100
101/// Record each path in `files` as viewed against `current_hash`, atomically.
102///
103/// Loads the existing ledger, upserts the marks, sets the stored hash to the
104/// current guide hash, then writes via a temp file + rename so a crash mid-write
105/// can never truncate the JSON. Returns the file count actually persisted.
106/// Swallows IO errors (logged at most by the caller); the viewed-state is a
107/// convenience, never load-bearing for the tour or the exit code.
108pub fn mark_viewed(cache_dir: &Path, files: &[String], current_hash: &str) -> std::io::Result<()> {
109    let mut state = load_viewed_state(cache_dir);
110    // A hash change means the prior marks no longer apply; reset the ledger to
111    // the current snapshot rather than mixing marks across two graph states.
112    if state.graph_snapshot_hash != current_hash {
113        state.entries.clear();
114    }
115    state.graph_snapshot_hash = current_hash.to_string();
116    let now = crate::vital_signs::chrono_timestamp();
117    for file in files {
118        state.entries.insert(
119            file.clone(),
120            ViewedEntry {
121                viewed_at: now.clone(),
122            },
123        );
124    }
125    write_atomic(cache_dir, &state)
126}
127
128/// Serialize `state` and write it to the ledger path via temp + rename.
129fn write_atomic(cache_dir: &Path, state: &ViewedState) -> std::io::Result<()> {
130    let path = fallow_config::walkthrough_state_path(cache_dir);
131    if let Some(parent) = path.parent() {
132        std::fs::create_dir_all(parent)?;
133    }
134    let json = serde_json::to_string_pretty(state)
135        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
136    let tmp = path.with_extension("json.tmp");
137    std::fs::write(&tmp, json.as_bytes())?;
138    std::fs::rename(&tmp, &path)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn temp_cache() -> tempfile::TempDir {
146        tempfile::tempdir().expect("tempdir")
147    }
148
149    #[test]
150    fn missing_file_loads_empty_default() {
151        let dir = temp_cache();
152        let state = load_viewed_state(dir.path());
153        assert!(state.entries.is_empty());
154        assert_eq!(state.version, VIEWED_STATE_VERSION);
155    }
156
157    #[test]
158    fn garbled_json_loads_empty_default() {
159        let dir = temp_cache();
160        let path = fallow_config::walkthrough_state_path(dir.path());
161        std::fs::write(&path, b"{ not json").expect("write");
162        let state = load_viewed_state(dir.path());
163        assert!(state.entries.is_empty());
164    }
165
166    #[test]
167    fn unknown_version_loads_empty_default() {
168        let dir = temp_cache();
169        let path = fallow_config::walkthrough_state_path(dir.path());
170        std::fs::write(
171            &path,
172            br#"{"version":999,"schema":"walkthrough-viewed-marks","graph_snapshot_hash":"h","entries":{"a.ts":{"viewed_at":"t"}}}"#,
173        )
174        .expect("write");
175        let state = load_viewed_state(dir.path());
176        assert!(state.entries.is_empty());
177    }
178
179    #[test]
180    fn mark_then_load_round_trips() {
181        let dir = temp_cache();
182        mark_viewed(dir.path(), &["src/a.ts".to_string()], "hash1").expect("mark");
183        let state = load_viewed_state(dir.path());
184        assert!(state.is_viewed("src/a.ts", "hash1"));
185        assert!(!state.is_viewed("src/b.ts", "hash1"));
186    }
187
188    #[test]
189    fn stale_hash_reads_as_not_viewed_but_keeps_entry() {
190        let dir = temp_cache();
191        mark_viewed(dir.path(), &["src/a.ts".to_string()], "hash1").expect("mark");
192        let state = load_viewed_state(dir.path());
193        // A different current hash: the mark is ignored for this render.
194        assert!(!state.is_viewed("src/a.ts", "hash2"));
195        // ...but the entry on disk is not deleted (carry-forward).
196        assert!(state.entries.contains_key("src/a.ts"));
197    }
198
199    #[test]
200    fn mark_against_new_hash_resets_prior_marks() {
201        let dir = temp_cache();
202        mark_viewed(dir.path(), &["src/a.ts".to_string()], "hash1").expect("mark a");
203        mark_viewed(dir.path(), &["src/b.ts".to_string()], "hash2").expect("mark b");
204        let state = load_viewed_state(dir.path());
205        assert!(state.is_viewed("src/b.ts", "hash2"));
206        // The hash1 mark was reset when the snapshot moved.
207        assert!(!state.entries.contains_key("src/a.ts"));
208    }
209
210    #[test]
211    fn is_viewed_only_matches_current_hash() {
212        let dir = temp_cache();
213        mark_viewed(
214            dir.path(),
215            &["src/a.ts".to_string(), "src/b.ts".to_string()],
216            "hash1",
217        )
218        .expect("mark");
219        let state = load_viewed_state(dir.path());
220        // Hash-matched files read as viewed; an un-marked file does not.
221        assert!(state.is_viewed("src/a.ts", "hash1"));
222        assert!(state.is_viewed("src/b.ts", "hash1"));
223        assert!(!state.is_viewed("src/c.ts", "hash1"));
224        // A stale snapshot hash matches nothing.
225        assert!(!state.is_viewed("src/a.ts", "stale"));
226        assert!(!state.is_viewed("src/b.ts", "stale"));
227    }
228
229    #[test]
230    fn empty_stored_hash_never_viewed() {
231        let state = ViewedState::default();
232        assert!(!state.is_viewed("a.ts", ""));
233    }
234}