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