use std::collections::BTreeMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
const VIEWED_STATE_VERSION: u32 = 1;
const VIEWED_STATE_SCHEMA: &str = "walkthrough-viewed-marks";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ViewedEntry {
pub viewed_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ViewedState {
pub version: u32,
pub schema: String,
#[serde(default)]
pub graph_snapshot_hash: String,
#[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 {
#[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)
}
}
#[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
}
pub fn mark_viewed(cache_dir: &Path, files: &[String], current_hash: &str) -> std::io::Result<()> {
let mut state = load_viewed_state(cache_dir);
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)
}
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());
assert!(!state.is_viewed("src/a.ts", "hash2"));
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"));
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());
assert!(state.is_viewed("src/a.ts", "hash1"));
assert!(state.is_viewed("src/b.ts", "hash1"));
assert!(!state.is_viewed("src/c.ts", "hash1"));
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", ""));
}
}