fallow_cli/
walkthrough_state.rs1use std::collections::BTreeMap;
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18
19const VIEWED_STATE_VERSION: u32 = 1;
22
23const VIEWED_STATE_SCHEMA: &str = "walkthrough-viewed-marks";
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ViewedEntry {
30 pub viewed_at: String,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ViewedState {
42 pub version: u32,
44 pub schema: String,
46 #[serde(default)]
48 pub graph_snapshot_hash: String,
49 #[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 #[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#[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
101pub fn mark_viewed(cache_dir: &Path, files: &[String], current_hash: &str) -> std::io::Result<()> {
109 let mut state = load_viewed_state(cache_dir);
110 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
128fn 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 assert!(!state.is_viewed("src/a.ts", "hash2"));
195 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 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 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 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}