use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JournalEntry {
pub path: String,
pub existed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub baseline: Option<String>,
#[serde(default)]
pub first_touched_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct Manifest {
#[serde(default)]
root: String,
#[serde(default)]
files: Vec<JournalEntry>,
}
pub struct Journal {
dir: PathBuf,
}
impl Journal {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
fn manifest_path(&self) -> PathBuf {
self.dir.join("manifest.json")
}
fn baseline_dir(&self) -> PathBuf {
self.dir.join("baseline")
}
fn load(&self) -> Manifest {
std::fs::read(self.manifest_path())
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
fn save(&self, manifest: &Manifest) -> Result<()> {
std::fs::create_dir_all(&self.dir)
.with_context(|| format!("creating {}", self.dir.display()))?;
let text = serde_json::to_string_pretty(manifest)?;
let tmp = self.manifest_path().with_extension("json.tmp");
std::fs::write(&tmp, text.as_bytes())
.with_context(|| format!("writing {}", tmp.display()))?;
std::fs::rename(&tmp, self.manifest_path())
.with_context(|| format!("replacing {}", self.manifest_path().display()))?;
Ok(())
}
pub fn record(&self, root: &str, rel: &str, current: Option<&[u8]>) -> Result<()> {
let mut manifest = self.load();
if !same_root(&manifest.root, root) {
self.clear()?;
manifest = Manifest {
root: root.to_string(),
files: Vec::new(),
};
} else if manifest.root.is_empty() {
manifest.root = root.to_string();
}
if manifest.files.iter().any(|e| e.path == rel) {
return Ok(()); }
let baseline = match current {
Some(bytes) => {
let name = baseline_name(rel);
let dir = self.baseline_dir();
std::fs::create_dir_all(&dir)
.with_context(|| format!("creating {}", dir.display()))?;
let path = dir.join(&name);
std::fs::write(&path, bytes)
.with_context(|| format!("writing {}", path.display()))?;
Some(name)
}
None => None,
};
manifest.files.push(JournalEntry {
path: rel.to_string(),
existed: current.is_some(),
baseline,
first_touched_at: Utc::now(),
});
self.save(&manifest)
}
pub fn describes(&self, root: &str) -> bool {
same_root(&self.load().root, root)
}
pub fn entries(&self) -> Vec<JournalEntry> {
let mut files = self.load().files;
files.sort_by_key(|e| e.first_touched_at);
files
}
pub fn baseline_of(&self, rel: &str) -> Option<Vec<u8>> {
let entry = self.load().files.into_iter().find(|e| e.path == rel)?;
let name = entry.baseline?;
std::fs::read(self.baseline_dir().join(name)).ok()
}
pub fn forget(&self, rel: &str) -> Result<()> {
let mut manifest = self.load();
let Some(index) = manifest.files.iter().position(|e| e.path == rel) else {
return Ok(());
};
let entry = manifest.files.remove(index);
if let Some(name) = entry.baseline {
let path = self.baseline_dir().join(name);
if path.exists() {
std::fs::remove_file(&path)
.with_context(|| format!("removing {}", path.display()))?;
}
}
self.save(&manifest)
}
pub fn clear(&self) -> Result<()> {
if self.dir.exists() {
std::fs::remove_dir_all(&self.dir)
.with_context(|| format!("clearing {}", self.dir.display()))?;
}
Ok(())
}
}
fn same_root(owner: &str, root: &str) -> bool {
if owner.is_empty() || owner == root {
return true;
}
matches!(
(Path::new(owner).canonicalize(), Path::new(root).canonicalize()),
(Ok(a), Ok(b)) if a == b
)
}
fn baseline_name(rel: &str) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write as _;
let digest = Sha256::digest(rel.as_bytes());
digest[..16]
.iter()
.fold(String::with_capacity(32), |mut name, b| {
let _ = write!(name, "{b:02x}");
name
})
}
#[cfg(test)]
mod tests {
use super::*;
fn journal() -> (tempfile::TempDir, Journal) {
let dir = tempfile::tempdir().unwrap();
let journal = Journal::new(dir.path().join("chat"));
(dir, journal)
}
#[test]
fn records_the_pre_image_and_reads_it_back() {
let (_d, j) = journal();
j.record("/proj", "src/a.rs", Some(b"before")).unwrap();
assert_eq!(j.baseline_of("src/a.rs").as_deref(), Some(&b"before"[..]));
let entries = j.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "src/a.rs");
assert!(entries[0].existed);
}
#[test]
fn only_the_first_touch_is_recorded() {
let (_d, j) = journal();
j.record("/proj", "src/a.rs", Some(b"original")).unwrap();
j.record("/proj", "src/a.rs", Some(b"after the first edit"))
.unwrap();
assert_eq!(j.baseline_of("src/a.rs").as_deref(), Some(&b"original"[..]));
assert_eq!(j.entries().len(), 1);
}
#[test]
fn a_created_file_is_journaled_as_absent() {
let (_d, j) = journal();
j.record("/proj", "src/new.rs", None).unwrap();
let entries = j.entries();
assert!(!entries[0].existed);
assert_eq!(entries[0].baseline, None);
assert_eq!(j.baseline_of("src/new.rs"), None);
}
#[test]
fn attaching_another_project_starts_over() {
let (_d, j) = journal();
j.record("/proj-one", "src/a.rs", Some(b"one")).unwrap();
j.record("/proj-two", "src/b.rs", Some(b"two")).unwrap();
let entries = j.entries();
assert_eq!(entries.len(), 1, "{entries:?}");
assert_eq!(entries[0].path, "src/b.rs");
assert_eq!(j.baseline_of("src/a.rs"), None);
}
#[test]
fn entries_come_back_oldest_touch_first() {
let (_d, j) = journal();
j.record("/p", "second.rs", Some(b"2")).unwrap();
j.record("/p", "third.rs", Some(b"3")).unwrap();
let path = j.manifest_path();
let mut doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
let files = doc["files"].as_array_mut().unwrap();
files.reverse();
files.push(serde_json::json!({
"path": "first.rs",
"existed": true,
"first_touched_at": DateTime::<Utc>::default(),
}));
std::fs::write(&path, serde_json::to_string(&doc).unwrap()).unwrap();
let order: Vec<String> = j.entries().into_iter().map(|e| e.path).collect();
assert_eq!(order, ["first.rs", "second.rs", "third.rs"], "{order:?}");
}
#[test]
fn a_row_without_a_timestamp_does_not_blank_the_journal() {
let (_d, j) = journal();
j.record("/p", "a.rs", Some(b"x")).unwrap();
let path = j.manifest_path();
let mut doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert!(
doc["files"][0]
.as_object_mut()
.unwrap()
.remove("first_touched_at")
.is_some(),
"the fixture has to remove a field that was actually there"
);
std::fs::write(&path, serde_json::to_string(&doc).unwrap()).unwrap();
let entries = j.entries();
assert_eq!(entries.len(), 1, "{entries:?}");
assert_eq!(entries[0].path, "a.rs");
assert_eq!(entries[0].first_touched_at, DateTime::<Utc>::default());
assert_eq!(
j.baseline_of("a.rs").as_deref(),
Some(&b"x"[..]),
"and the pre-image is still reachable"
);
}
#[test]
fn a_journal_says_which_project_it_describes() {
let (_d, j) = journal();
assert!(
j.describes("/anywhere"),
"an empty journal has nothing to misattribute"
);
j.record("/proj-one", "src/a.rs", Some(b"one")).unwrap();
assert!(j.describes("/proj-one"));
assert!(
!j.describes("/proj-two"),
"a different project must not be able to read these baselines"
);
assert!(!j.describes(""), "an unnamed root is not this project");
}
#[test]
fn one_directory_spelled_two_ways_is_still_the_same_project() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("proj");
std::fs::create_dir_all(root.join("sub")).unwrap();
let j = Journal::new(dir.path().join("journal"));
j.record(&root.to_string_lossy(), "a.rs", Some(b"x"))
.unwrap();
let roundabout = root.join("sub").join("..").to_string_lossy().into_owned();
assert_ne!(roundabout, root.to_string_lossy(), "a different spelling");
assert!(
j.describes(&roundabout),
"…of the same directory: {roundabout}"
);
}
#[test]
fn separators_and_case_do_not_collide() {
assert_ne!(baseline_name("src/a.rs"), baseline_name("src/b.rs"));
assert_ne!(baseline_name("src/a.rs"), baseline_name("SRC/A.RS"));
assert_eq!(baseline_name("src/a.rs"), baseline_name("src/a.rs"));
let name = baseline_name("../../etc/passwd");
assert!(
!name.contains('/') && !name.contains('\\') && !name.contains('.'),
"got: {name}"
);
}
#[test]
fn the_baseline_name_does_not_move_between_builds() {
assert_eq!(
baseline_name("src/a.rs"),
"bdfc5619650b795d1ffec5e8af3154a9"
);
}
#[test]
fn a_journal_that_cannot_be_written_reports_it() {
let dir = tempfile::tempdir().unwrap();
let blocked = dir.path().join("chat");
std::fs::write(&blocked, "not a directory").unwrap();
let j = Journal::new(&blocked);
assert!(j.record("/proj", "src/a.rs", Some(b"x")).is_err());
}
#[test]
fn forget_drops_the_row_and_its_baseline() {
let (_d, j) = journal();
j.record("/proj", "src/a.rs", Some(b"a")).unwrap();
j.record("/proj", "src/b.rs", Some(b"b")).unwrap();
j.forget("src/a.rs").unwrap();
let entries = j.entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, "src/b.rs");
assert_eq!(j.baseline_of("src/a.rs"), None);
assert_eq!(j.baseline_of("src/b.rs").as_deref(), Some(&b"b"[..]));
assert!(j.forget("src/a.rs").is_ok());
}
#[test]
fn clear_removes_everything() {
let (_d, j) = journal();
j.record("/proj", "src/a.rs", Some(b"x")).unwrap();
j.clear().unwrap();
assert!(j.entries().is_empty());
assert_eq!(j.baseline_of("src/a.rs"), None);
assert!(j.clear().is_ok());
}
}