difflore_cli/hooks/session_banner/watermark.rs
1//! Per-project "last session start" watermark.
2//!
3//! Stored at `~/.difflore/projects/{hash}/last-session-start.json` as a
4//! one-shot JSON blob `{ "ts_ms": …, "client": "…" }`. Owning this file
5//! is a single-purpose responsibility — no other code reads or writes
6//! it — so the format can change freely as long as the read path stays
7//! permissive about missing/malformed input (silent fallback to `None`,
8//! never panics).
9//!
10//! Concurrent SessionStart fires from two agent windows in the same repo
11//! could race the write here. That's fine: whichever fires last wins,
12//! and the only consequence is one of the two banners may show a
13//! slightly older `prev_ts`. We deliberately do NOT take a lock — the
14//! whole helper is on the hot path and a contended file lock would
15//! defeat the 50 ms budget.
16
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21/// One row in the watermark file. `client` is purely diagnostic — the
22/// banner pipeline doesn't branch on it today, but storing it lets a
23/// future audit answer "which agent last opened this repo?" without
24/// crawling the fire log.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Watermark {
27 /// Unix epoch milliseconds (UTC) of the watermark write.
28 pub ts_ms: i64,
29 /// Adapter name the SessionStart came from (`"claude-code"`,
30 /// `"cursor"`, …). Diagnostic only.
31 pub client: String,
32}
33
34/// Resolve the watermark file path under the canonical
35/// `~/.difflore/projects/{hash}/` layout. Does NOT create the parent
36/// directory — the write helper handles that, and the read helper is
37/// satisfied with a missing path (returns `None`).
38fn watermark_path(project_hash: &str) -> PathBuf {
39 difflore_core::db::project_index_dir(project_hash).join("last-session-start.json")
40}
41
42/// Read the watermark for the given project hash. Returns `None` when:
43/// * the file doesn't exist (first session on this repo),
44/// * the file exists but is unreadable,
45/// * the JSON fails to parse.
46///
47/// All three collapse into "treat this like a fresh repo" — the banner
48/// then shows everything learned to date, capped at the row limit.
49pub fn read_watermark(project_hash: &str) -> Option<Watermark> {
50 read_watermark_at(&watermark_path(project_hash))
51}
52
53/// Write the watermark for the given project hash. Best-effort:
54/// caller can ignore the `Result` via `let _ = …`. Creates the parent
55/// dir on demand — first-ever SessionStart in a repo finds
56/// `~/.difflore/projects/{hash}/` missing.
57pub fn write_watermark(project_hash: &str, wm: &Watermark) -> Result<(), String> {
58 write_watermark_at(&watermark_path(project_hash), wm)
59}
60
61/// Pure-path variant of [`read_watermark`]. Tests call this with a
62/// tempdir-rooted path instead of mutating `DIFFLORE_HOME`, which would
63/// require an `unsafe` block (forbidden by the workspace's
64/// `unsafe_code = "deny"` lint) and would race other tests reading the
65/// same env var.
66fn read_watermark_at(path: &Path) -> Option<Watermark> {
67 let raw = std::fs::read_to_string(path).ok()?;
68 serde_json::from_str::<Watermark>(&raw).ok()
69}
70
71/// Pure-path variant of [`write_watermark`]. See [`read_watermark_at`]
72/// for the testability rationale.
73fn write_watermark_at(path: &Path, wm: &Watermark) -> Result<(), String> {
74 if let Some(parent) = path.parent() {
75 std::fs::create_dir_all(parent).map_err(|e| format!("create dir: {e}"))?;
76 }
77 let body = serde_json::to_string(wm).map_err(|e| format!("serialize: {e}"))?;
78 // Atomic-ish write via tempfile + rename: prevents a crashed write
79 // from leaving a half-written JSON that the next read would treat
80 // as "fresh repo" and re-show every rule.
81 let tmp = path.with_extension("json.tmp");
82 std::fs::write(&tmp, body).map_err(|e| format!("write tmp: {e}"))?;
83 std::fs::rename(&tmp, path).map_err(|e| format!("rename: {e}"))?;
84 Ok(())
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 /// End-to-end roundtrip against a tempdir-rooted path.
92 #[test]
93 fn write_then_read_returns_same_value() {
94 let tmp = tempfile::tempdir().expect("tempdir");
95 let path = tmp.path().join("last-session-start.json");
96 let wm = Watermark {
97 ts_ms: 1_700_000_000_000,
98 client: "claude-code".to_owned(),
99 };
100 write_watermark_at(&path, &wm).expect("write ok");
101 let back = read_watermark_at(&path).expect("read ok");
102 assert_eq!(back.ts_ms, wm.ts_ms);
103 assert_eq!(back.client, wm.client);
104 }
105
106 #[test]
107 fn read_missing_returns_none() {
108 let tmp = tempfile::tempdir().expect("tempdir");
109 let path = tmp.path().join("never-written.json");
110 assert!(read_watermark_at(&path).is_none());
111 }
112
113 #[test]
114 fn read_garbage_json_returns_none() {
115 let tmp = tempfile::tempdir().expect("tempdir");
116 let path = tmp.path().join("garbage.json");
117 std::fs::write(&path, "not json at all").expect("write");
118 assert!(read_watermark_at(&path).is_none());
119 }
120
121 #[test]
122 fn write_creates_missing_parent_dirs() {
123 // A fresh repo's `~/.difflore/projects/{hash}/` directory
124 // doesn't exist until the watermark write runs. Production
125 // would surface this as a hot-path stall if `write` didn't
126 // mkdir-p — regression-guard the behaviour here.
127 let tmp = tempfile::tempdir().expect("tempdir");
128 let path = tmp
129 .path()
130 .join("projects")
131 .join("abc123")
132 .join("last-session-start.json");
133 assert!(!path.parent().expect("parent").exists(), "precondition");
134 let wm = Watermark {
135 ts_ms: 1,
136 client: "cursor".to_owned(),
137 };
138 write_watermark_at(&path, &wm).expect("write ok");
139 assert!(path.exists(), "watermark file missing post-write");
140 }
141}