gobby_code/index/indexer/
freshness_probe.rs1use std::path::Path;
12use std::time::{Duration, SystemTime};
13
14use crate::index::walker;
15
16use super::util::DEFAULT_EXCLUDES;
17
18const SKEW_MARGIN: Duration = Duration::from_secs(2);
25
26pub fn project_changed_since(
38 project_root: &Path,
39 last_indexed_at: SystemTime,
40 indexed_paths: &[String],
41 options: walker::DiscoveryOptions,
42) -> bool {
43 let threshold = last_indexed_at
44 .checked_sub(SKEW_MARGIN)
45 .unwrap_or(last_indexed_at);
46
47 let (candidates, content_only) =
48 walker::discover_files_with_options(project_root, DEFAULT_EXCLUDES, options);
49
50 for path in candidates.iter().chain(content_only.iter()) {
55 match path.metadata() {
56 Ok(meta) => match meta.modified() {
57 Ok(modified) if modified <= threshold => {}
58 Ok(_) => return true,
59 Err(error) => {
60 log::debug!(
61 "treating project as changed: failed to read mtime for {}: {error}",
62 path.display()
63 );
64 return true;
65 }
66 },
67 Err(error) => {
68 log::debug!(
69 "treating project as changed: failed to read metadata for {}: {error}",
70 path.display()
71 );
72 return true;
73 }
74 }
75 }
76
77 indexed_paths
79 .iter()
80 .any(|rel| !project_root.join(rel).exists())
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86 use std::fs::File;
87 use std::path::PathBuf;
88
89 fn write_file(root: &Path, rel: &str, contents: &[u8]) -> PathBuf {
90 let path = root.join(rel);
91 if let Some(parent) = path.parent() {
92 std::fs::create_dir_all(parent).expect("create parent");
93 }
94 std::fs::write(&path, contents).expect("write file");
95 path
96 }
97
98 fn set_mtime(path: &Path, time: SystemTime) {
99 File::options()
100 .write(true)
101 .open(path)
102 .expect("open file to set mtime")
103 .set_modified(time)
104 .expect("set mtime");
105 }
106
107 fn base_time() -> SystemTime {
110 SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
111 }
112
113 fn default_options() -> walker::DiscoveryOptions {
114 walker::DiscoveryOptions::default()
115 }
116
117 #[test]
118 fn reports_no_change_when_everything_predates_last_index() {
119 let tmp = tempfile::tempdir().expect("tempdir");
120 let root = tmp.path();
121 let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
122 let readme = write_file(root, "README.md", b"# Title\n");
123
124 let base = base_time();
125 set_mtime(&lib, base);
126 set_mtime(&readme, base);
127
128 let last = base + Duration::from_secs(3600);
130 let indexed = vec!["src/lib.rs".to_string(), "README.md".to_string()];
131
132 assert!(!project_changed_since(
133 root,
134 last,
135 &indexed,
136 default_options()
137 ));
138 }
139
140 #[test]
141 fn reports_change_when_a_file_is_modified_after_last_index() {
142 let tmp = tempfile::tempdir().expect("tempdir");
143 let root = tmp.path();
144 let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
145 set_mtime(&lib, base_time() + Duration::from_secs(7200));
146
147 let last = base_time() + Duration::from_secs(3600);
148 let indexed = vec!["src/lib.rs".to_string()];
149
150 assert!(project_changed_since(
151 root,
152 last,
153 &indexed,
154 default_options()
155 ));
156 }
157
158 #[test]
159 fn reports_change_for_newly_added_file() {
160 let tmp = tempfile::tempdir().expect("tempdir");
163 let root = tmp.path();
164 let added = write_file(root, "src/new.rs", b"fn added() {}\n");
165 set_mtime(&added, base_time() + Duration::from_secs(7200));
166
167 let last = base_time() + Duration::from_secs(3600);
168 let indexed: Vec<String> = Vec::new();
169
170 assert!(project_changed_since(
171 root,
172 last,
173 &indexed,
174 default_options()
175 ));
176 }
177
178 #[test]
179 fn reports_change_when_indexed_file_is_deleted() {
180 let tmp = tempfile::tempdir().expect("tempdir");
181 let root = tmp.path();
182 let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
183 set_mtime(&lib, base_time());
184
185 let last = base_time() + Duration::from_secs(3600);
186 let indexed = vec!["src/lib.rs".to_string(), "src/gone.rs".to_string()];
188
189 assert!(project_changed_since(
190 root,
191 last,
192 &indexed,
193 default_options()
194 ));
195 }
196
197 #[test]
198 fn skew_margin_boundary_only_ever_makes_the_gate_more_eager() {
199 let tmp = tempfile::tempdir().expect("tempdir");
200 let root = tmp.path();
201 let lib = write_file(root, "src/lib.rs", b"fn main() {}\n");
202 let mtime = base_time();
203 set_mtime(&lib, mtime);
204 let indexed = vec!["src/lib.rs".to_string()];
205
206 let within_margin = mtime + Duration::from_secs(1);
209 assert!(project_changed_since(
210 root,
211 within_margin,
212 &indexed,
213 default_options()
214 ));
215
216 let at_margin = mtime + SKEW_MARGIN;
219 assert!(!project_changed_since(
220 root,
221 at_margin,
222 &indexed,
223 default_options()
224 ));
225
226 let beyond_margin = mtime + Duration::from_secs(3);
229 assert!(!project_changed_since(
230 root,
231 beyond_margin,
232 &indexed,
233 default_options()
234 ));
235 }
236
237 #[test]
238 fn gitignored_new_files_follow_respect_gitignore_setting() {
239 let tmp = tempfile::tempdir().expect("tempdir");
240 let root = tmp.path();
241 std::fs::create_dir(root.join(".git")).expect("git dir");
242 write_file(root, ".gitignore", b"ignored.rs\n");
243 let ignored = write_file(root, "ignored.rs", b"fn ignored() {}\n");
244 set_mtime(&ignored, base_time() + Duration::from_secs(7200));
245
246 let last = base_time() + Duration::from_secs(3600);
247 let indexed: Vec<String> = Vec::new();
248
249 assert!(!project_changed_since(
250 root,
251 last,
252 &indexed,
253 walker::DiscoveryOptions {
254 respect_gitignore: true
255 }
256 ));
257 assert!(project_changed_since(
258 root,
259 last,
260 &indexed,
261 walker::DiscoveryOptions {
262 respect_gitignore: false
263 }
264 ));
265 }
266}