Skip to main content

gobby_code/index/indexer/
freshness_probe.rs

1//! Lock-free, hash-free freshness pre-gate.
2//!
3//! `project_changed_since` answers one question without taking the per-project
4//! advisory lock and without hashing any file: has anything under the project
5//! root changed since the recorded `last_indexed_at`? Read-time freshness calls
6//! this *before* the lock so the common no-change case is cheap and never prints
7//! "refresh already running". When it reports a change, the caller falls through
8//! to the existing lock + incremental reconcile, which is exactly as correct as
9//! before.
10
11use std::path::Path;
12use std::time::{Duration, SystemTime};
13
14use crate::index::walker;
15
16use super::util::DEFAULT_EXCLUDES;
17
18/// Clock-skew / mtime-granularity margin. Subtracted from `last_indexed_at`
19/// before comparing file mtimes, so the gate only ever errs toward refreshing
20/// and can never miss a real change. Absorbs host-vs-PostgreSQL (docker) clock
21/// skew and same-second mtime granularity. It is not a distributed-filesystem
22/// correctness guarantee; larger NFS or multi-host clock drift is reconciled by
23/// the periodic maintenance full re-hash sweep.
24const SKEW_MARGIN: Duration = Duration::from_secs(2);
25
26/// Returns `true` if any discovered file is newer than `last_indexed_at` (a
27/// modify or add) or any previously indexed path no longer exists on disk (a
28/// delete or rename), and `false` only when the on-disk tree still matches the
29/// recorded index. A `false` result lets the caller skip the advisory lock and
30/// the full re-hash entirely.
31///
32/// Discovery mirrors the indexer (`walker::discover_files` with
33/// `DEFAULT_EXCLUDES`), so the `.gobby/plans/**/*.md` allowlist and every other
34/// exclusion stay in lockstep with what actually gets indexed — including the
35/// internal `.gobby/plans/*.md` edits the daemon trigger never forwards.
36/// Short-circuits on the first sign of change.
37pub fn project_changed_since(
38    project_root: &Path,
39    last_indexed_at: SystemTime,
40    indexed_paths: &[String],
41) -> bool {
42    let threshold = last_indexed_at
43        .checked_sub(SKEW_MARGIN)
44        .unwrap_or(last_indexed_at);
45
46    let (candidates, content_only) = walker::discover_files(project_root, DEFAULT_EXCLUDES);
47
48    // Modify / add: a discovered file whose mtime is newer than the threshold.
49    // A freshly added file also carries a recent mtime, so adds are caught here
50    // without a fragile path-set diff. An unreadable mtime is treated as a
51    // change, so we never skip a refresh for a file we cannot stat.
52    for path in candidates.iter().chain(content_only.iter()) {
53        match path.metadata() {
54            Ok(meta) => match meta.modified() {
55                Ok(modified) if modified <= threshold => {}
56                Ok(_) => return true,
57                Err(error) => {
58                    log::debug!(
59                        "treating project as changed: failed to read mtime for {}: {error}",
60                        path.display()
61                    );
62                    return true;
63                }
64            },
65            Err(error) => {
66                log::debug!(
67                    "treating project as changed: failed to read metadata for {}: {error}",
68                    path.display()
69                );
70                return true;
71            }
72        }
73    }
74
75    // Delete / rename: a path recorded in the index that is gone from disk.
76    indexed_paths
77        .iter()
78        .any(|rel| !project_root.join(rel).exists())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use std::fs::File;
85    use std::path::PathBuf;
86
87    fn write_file(root: &Path, rel: &str, contents: &[u8]) -> PathBuf {
88        let path = root.join(rel);
89        if let Some(parent) = path.parent() {
90            std::fs::create_dir_all(parent).expect("create parent");
91        }
92        std::fs::write(&path, contents).expect("write file");
93        path
94    }
95
96    fn set_mtime(path: &Path, time: SystemTime) {
97        File::options()
98            .write(true)
99            .open(path)
100            .expect("open file to set mtime")
101            .set_modified(time)
102            .expect("set mtime");
103    }
104
105    /// A fixed, whole-second base instant well in the past, so the arithmetic
106    /// never underflows and 1-second-granularity filesystems round-trip it.
107    fn base_time() -> SystemTime {
108        SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
109    }
110
111    #[test]
112    fn reports_no_change_when_everything_predates_last_index() {
113        let tmp = tempfile::tempdir().expect("tempdir");
114        let root = tmp.path();
115        let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
116        let readme = write_file(root, "README.md", b"# Title\n");
117
118        let base = base_time();
119        set_mtime(&lib, base);
120        set_mtime(&readme, base);
121
122        // last_indexed_at is well after every file's mtime.
123        let last = base + Duration::from_secs(3600);
124        let indexed = vec!["src/lib.rs".to_string(), "README.md".to_string()];
125
126        assert!(!project_changed_since(root, last, &indexed));
127    }
128
129    #[test]
130    fn reports_change_when_a_file_is_modified_after_last_index() {
131        let tmp = tempfile::tempdir().expect("tempdir");
132        let root = tmp.path();
133        let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
134        set_mtime(&lib, base_time() + Duration::from_secs(7200));
135
136        let last = base_time() + Duration::from_secs(3600);
137        let indexed = vec!["src/lib.rs".to_string()];
138
139        assert!(project_changed_since(root, last, &indexed));
140    }
141
142    #[test]
143    fn reports_change_for_newly_added_file() {
144        // A new (unindexed) file carries a recent mtime, so the modify/add scan
145        // trips even though it is absent from indexed_paths.
146        let tmp = tempfile::tempdir().expect("tempdir");
147        let root = tmp.path();
148        let added = write_file(root, "src/new.rs", b"fn added() {}\n");
149        set_mtime(&added, base_time() + Duration::from_secs(7200));
150
151        let last = base_time() + Duration::from_secs(3600);
152        let indexed: Vec<String> = Vec::new();
153
154        assert!(project_changed_since(root, last, &indexed));
155    }
156
157    #[test]
158    fn reports_change_when_indexed_file_is_deleted() {
159        let tmp = tempfile::tempdir().expect("tempdir");
160        let root = tmp.path();
161        let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
162        set_mtime(&lib, base_time());
163
164        let last = base_time() + Duration::from_secs(3600);
165        // "src/gone.rs" is recorded as indexed but no longer exists on disk.
166        let indexed = vec!["src/lib.rs".to_string(), "src/gone.rs".to_string()];
167
168        assert!(project_changed_since(root, last, &indexed));
169    }
170
171    #[test]
172    fn skew_margin_boundary_only_ever_makes_the_gate_more_eager() {
173        let tmp = tempfile::tempdir().expect("tempdir");
174        let root = tmp.path();
175        let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
176        let mtime = base_time();
177        set_mtime(&lib, mtime);
178        let indexed = vec!["src/lib.rs".to_string()];
179
180        // File is 1s older than last_indexed_at — inside the 2s margin, so the
181        // gate refreshes (threshold = last - 2s = mtime - 1s < mtime).
182        let within_margin = mtime + Duration::from_secs(1);
183        assert!(project_changed_since(root, within_margin, &indexed));
184
185        // File sits exactly at the boundary (threshold == mtime, mtime <=
186        // threshold), so it counts as unchanged.
187        let at_margin = mtime + SKEW_MARGIN;
188        assert!(!project_changed_since(root, at_margin, &indexed));
189
190        // File is 3s older than last_indexed_at — beyond the 2s margin, so the
191        // gate skips (threshold = last - 2s = mtime + 1s >= mtime).
192        let beyond_margin = mtime + Duration::from_secs(3);
193        assert!(!project_changed_since(root, beyond_margin, &indexed));
194    }
195}