reference_query/index/mod.rs
1//! Indexing — walk a checkout, extract symbols, persist incrementally.
2//!
3//! Decoupled from search: it only writes. Unchanged files (same content hash)
4//! are skipped, and coverage is recorded so search can judge its own confidence.
5
6use std::collections::{HashMap, HashSet};
7use std::hash::{Hash, Hasher};
8use std::path::Path;
9use std::process::Command;
10use std::time::{Duration, Instant, UNIX_EPOCH};
11
12use ignore::WalkBuilder;
13
14use crate::core::RepoIdentity;
15use crate::lang;
16use crate::store::Store;
17
18/// Outcome of an indexing run.
19#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
20pub struct Stats {
21 /// Files matching a known language that were walked.
22 pub files_seen: usize,
23 /// Files (re)parsed this run (unchanged files are skipped).
24 pub files_indexed: usize,
25 /// Symbols written this run.
26 pub symbols: usize,
27}
28
29/// Index the whole repository rooted at `root`.
30pub fn index_path(store: &mut Store, root: &Path) -> Result<Stats, Box<dyn std::error::Error>> {
31 index_under(store, root, &[])
32}
33
34/// Index `root`, or — when `subdirs` is non-empty — only those repo-relative
35/// subtrees of it. Unbounded: an explicit index is thorough. A whole-repo index
36/// also reconciles deletions; a subtree index is a *seed* (it gets those files
37/// in first) that leaves coverage `warming`, so normal warming continues over
38/// the rest of the repo through use.
39pub fn index_under(
40 store: &mut Store,
41 root: &Path,
42 subdirs: &[String],
43) -> Result<Stats, Box<dyn std::error::Error>> {
44 run_index(store, root, &[], subdirs, None, None, None)
45}
46
47/// Lowercase the alphanumeric chars of `s` — the normal form for loose,
48/// separator-insensitive path matching.
49fn alnum_lower(s: &str) -> String {
50 s.chars()
51 .filter(|c| c.is_alphanumeric())
52 .map(|c| c.to_ascii_lowercase())
53 .collect()
54}
55
56/// Move the candidate paths whose *filename* looks relevant to the query to the
57/// front (preserving order within each group), so a warming pass parses likely
58/// files first. Deliberately generous: a stem qualifies if it shares any ~4-char
59/// run with the query — parsing is cheap, so over-including a near-match beats
60/// missing the target. `employeescontroller` flags employee / employers /
61/// EmpController, tosses companies. Matched on the filename stem (not the whole
62/// path), so a common directory like `controllers/` doesn't flag the whole tree.
63/// String-only over the in-memory list — no file reads. No-op for an empty query.
64fn prioritize_by_path(
65 paths: Vec<std::path::PathBuf>,
66 _root: &Path,
67 query: Option<&str>,
68) -> Vec<std::path::PathBuf> {
69 let needle = alnum_lower(query.unwrap_or(""));
70 let k = needle.len().min(4);
71 if k == 0 {
72 return paths;
73 }
74 let kgrams: std::collections::HashSet<&[u8]> = needle.as_bytes().windows(k).collect();
75 // one pass, reusing a scratch buffer for the normalized stem and an O(1)
76 // k-gram lookup — string-only, no per-file allocation
77 let mut prio = Vec::new();
78 let mut rest = Vec::new();
79 let mut stem = String::new();
80 for p in paths {
81 stem.clear();
82 if let Some(s) = p.file_stem() {
83 stem.extend(
84 s.to_string_lossy()
85 .chars()
86 .filter(|c| c.is_alphanumeric())
87 .map(|c| c.to_ascii_lowercase()),
88 );
89 }
90 // shares a k-char run with the query (a common substring of length ≥ k)
91 if stem.as_bytes().windows(k).any(|w| kgrams.contains(w)) {
92 prio.push(p);
93 } else {
94 rest.push(p);
95 }
96 }
97 prio.extend(rest);
98 prio
99}
100
101/// Opportunistic, time-bounded indexing — warm the index a little per call so no
102/// single query blocks on a full walk of a large repo. `active` (branch) files
103/// are parsed first and ignore the budget (the working set stays fresh); then the
104/// walk streams the rest, honoring `budget`. When `query` is set, files whose
105/// *path* matches it are parsed first (a cheap, in-memory reorder of the
106/// candidate list — no file reads), so a relevant symbol indexes fast. A sweep
107/// that finishes within budget marks coverage `complete`, else `warming`.
108pub fn index_budgeted(
109 store: &mut Store,
110 root: &Path,
111 active: &[String],
112 budget: Duration,
113 query: Option<&str>,
114) -> Result<Stats, Box<dyn std::error::Error>> {
115 run_index(store, root, active, &[], Some(budget), query, None)
116}
117
118/// Like [`index_budgeted`], but the pass stops promptly when `cancel` is set —
119/// the interactive cold-start escalation (see the CLI's search path) runs a long,
120/// generous-budget warm and lets the user abort it with Ctrl-C without losing the
121/// batches already committed.
122pub fn index_budgeted_cancellable(
123 store: &mut Store,
124 root: &Path,
125 active: &[String],
126 budget: Duration,
127 query: Option<&str>,
128 cancel: &std::sync::atomic::AtomicBool,
129) -> Result<Stats, Box<dyn std::error::Error>> {
130 run_index(store, root, active, &[], Some(budget), query, Some(cancel))
131}
132
133/// Max files a single *bounded* (warming) pass walks before it stops. The walk
134/// is cheap (stat-only), but on a huge repo it must not run the whole tree
135/// (memory + latency); the deadline cuts it short sooner. An explicit `--index`
136/// (unbounded) ignores this and walks everything. Overridable via
137/// `RQ_COLLECT_CAP` (tuning / deterministic tests).
138const COLLECT_CAP: usize = 50_000;
139
140fn collect_cap() -> usize {
141 std::env::var("RQ_COLLECT_CAP")
142 .ok()
143 .and_then(|v| v.parse().ok())
144 .unwrap_or(COLLECT_CAP)
145}
146
147/// Parse workers the background warmer uses (`--jobs`); 0 = auto.
148static PARSE_JOBS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
149
150/// Set the parse-worker count (from `--jobs`/`RQ_JOBS`); 0 restores auto.
151pub fn set_parse_jobs(n: usize) {
152 PARSE_JOBS.store(n, std::sync::atomic::Ordering::Relaxed);
153}
154
155/// Parse workers for one indexer pass — the configured value, else `RQ_JOBS`,
156/// else an auto default. Parsing is CPU-bound but writes serialize through one
157/// SQLite writer, so flooding every core rarely pays; the default caps at 8.
158pub fn parse_jobs() -> usize {
159 let configured = PARSE_JOBS.load(std::sync::atomic::Ordering::Relaxed);
160 if configured > 0 {
161 return configured;
162 }
163 if let Some(n) = std::env::var("RQ_JOBS").ok().and_then(|v| v.parse().ok())
164 && n > 0
165 {
166 return n;
167 }
168 let cores = std::thread::available_parallelism()
169 .map(|n| n.get())
170 .unwrap_or(1);
171 cores.clamp(1, 8)
172}
173
174/// Files buffered before a streaming write commits them — bounds per-transaction
175/// size and how much parsed-but-unwritten work a cut-short pass can lose.
176const WRITE_BATCH: usize = 512;
177
178/// Accumulates parsed files and commits them to the store in `WRITE_BATCH`
179/// chunks, so a long or cut-short index persists incrementally rather than in one
180/// final write. The `stream_walk` sink for `run_index`.
181struct BatchWriter<'a> {
182 store: &'a mut Store,
183 repo_id: i64,
184 buf: Vec<crate::store::FileSymbols>,
185 files: usize,
186 symbols: usize,
187 /// Cumulative time spent in `replace_files` (the single-writer store path) —
188 /// surfaced under `-v` so we can see write vs. walk/parse contention.
189 write_time: Duration,
190}
191
192impl<'a> BatchWriter<'a> {
193 fn new(store: &'a mut Store, repo_id: i64) -> Self {
194 Self {
195 store,
196 repo_id,
197 buf: Vec::new(),
198 files: 0,
199 symbols: 0,
200 write_time: Duration::ZERO,
201 }
202 }
203
204 fn push(&mut self, fs: crate::store::FileSymbols) -> Result<(), Box<dyn std::error::Error>> {
205 self.buf.push(fs);
206 if self.buf.len() >= WRITE_BATCH {
207 self.flush()?;
208 }
209 Ok(())
210 }
211
212 fn flush(&mut self) -> Result<(), Box<dyn std::error::Error>> {
213 if !self.buf.is_empty() {
214 let t = Instant::now();
215 let (f, sy) = self.store.replace_files(self.repo_id, &self.buf)?;
216 self.write_time += t.elapsed();
217 self.files += f;
218 self.symbols += sy;
219 self.buf.clear();
220 }
221 Ok(())
222 }
223}
224
225/// Source-file candidates from `git ls-files` — read out of git's index, not by
226/// walking the filesystem. On a huge repo this is the difference between
227/// answering and timing out: enumeration is O(index read), and source-extension
228/// pathspecs make git hand back only files we can parse, so warming never burns
229/// its budget re-traversing non-source trees. Tracked files only (untracked are
230/// caught by an explicit `rq --index`'s filesystem walk). `None` outside a git
231/// work tree, so the caller falls back to walking the filesystem.
232fn git_source_candidates(root: &Path) -> Option<Vec<std::path::PathBuf>> {
233 if !is_git_repo(root) {
234 return None;
235 }
236 let globs: Vec<String> = lang::registry()
237 .iter()
238 .flat_map(|p| p.extensions().iter().map(|e| format!("*.{e}")))
239 .collect();
240 let mut cmd = Command::new("git");
241 cmd.arg("-C")
242 .arg(root)
243 .args(["ls-files", "-z", "--cached", "--"])
244 .args(&globs);
245 let out = cmd.output().ok()?;
246 if !out.status.success() {
247 return None;
248 }
249 Some(
250 out.stdout
251 .split(|&b| b == 0)
252 .filter(|s| !s.is_empty())
253 .map(|s| root.join(String::from_utf8_lossy(s).as_ref()))
254 .collect(),
255 )
256}
257
258/// A lazy, streaming filesystem walk of `roots` yielding file paths — the
259/// fallback when git can't enumerate (an explicit unbounded index, or a non-git
260/// dir). Honors `.gitignore`/hidden rules via the `ignore` crate.
261fn fs_walk_candidates(roots: Vec<std::path::PathBuf>) -> impl Iterator<Item = std::path::PathBuf> {
262 roots.into_iter().flat_map(|root| {
263 WalkBuilder::new(&root)
264 .build()
265 .filter_map(Result::ok)
266 .filter(|e| e.file_type().is_some_and(|t| t.is_file()))
267 .map(ignore::DirEntry::into_path)
268 })
269}
270
271/// The one fused walk→parse→consume engine. A walk thread streams the source
272/// paths that `keep` selects (in walk order, the instant each is found) through a
273/// bounded channel to a pool of parse workers; the workers parse in parallel
274/// (skipping files that lack `needle`, when set) and stream each result to `sink`
275/// on the calling thread. Bounded channels back-pressure the walk and workers so
276/// neither runs ahead into unbounded memory; `deadline`/`cap` bound the pass.
277/// `seen` is seeded by the caller and returned holding every source file walked
278/// (for deletion reconcile). The bool is whether walk *and* parse finished within
279/// budget. Streaming — never collect-then-parse — is what keeps a pass too big to
280/// finish from making zero progress.
281///
282/// `run_index` sinks to the store (writing in batches via [`BatchWriter`]); the
283/// live [`scan`] sinks into a `Vec` it returns — same engine, different consumer.
284#[allow(clippy::too_many_arguments)]
285fn stream_walk(
286 root: &Path,
287 candidates: impl Iterator<Item = std::path::PathBuf> + Send,
288 deadline: Option<Instant>,
289 cap: Option<usize>,
290 needle: Option<&[u8]>,
291 seen: HashSet<String>,
292 keep: impl Fn(&str, &Path) -> bool + Send,
293 cancel: Option<&std::sync::atomic::AtomicBool>,
294 mut sink: impl FnMut(crate::store::FileSymbols) -> Result<(), Box<dyn std::error::Error>>,
295) -> Result<(HashSet<String>, bool), Box<dyn std::error::Error>> {
296 use std::sync::atomic::{AtomicBool, Ordering};
297 use std::sync::{Arc, Mutex};
298
299 let workers = parse_jobs();
300 let parse_incomplete = AtomicBool::new(false);
301 let (path_tx, path_rx) = std::sync::mpsc::sync_channel::<std::path::PathBuf>(1024);
302 let (res_tx, res_rx) = std::sync::mpsc::sync_channel::<crate::store::FileSymbols>(1024);
303 let path_rx = Arc::new(Mutex::new(path_rx));
304
305 let (seen, walk_finished) = std::thread::scope(|s| -> Result<_, Box<dyn std::error::Error>> {
306 // walk thread: stream every kept source path to the workers, in order, the
307 // instant it's found. No buffering or deferral — on a repo too big to
308 // finish in budget, anything held back would never be sent.
309 let walk = s.spawn(move || {
310 let mut seen = seen;
311 let mut finished = true;
312 let mut processed = 0usize;
313 for path in candidates {
314 if past(deadline) || cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
315 finished = false;
316 break;
317 }
318 let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
319 continue;
320 };
321 if lang::plugin_for_extension(ext).is_none() {
322 continue;
323 }
324 let rel = path
325 .strip_prefix(root)
326 .unwrap_or(&path)
327 .to_string_lossy()
328 .into_owned();
329 if !seen.insert(rel.clone()) {
330 continue; // already handled (active file), or a duplicate
331 }
332 if !keep(&rel, &path) {
333 continue; // caller skipped it (unchanged / already indexed)
334 }
335 if path_tx.send(path).is_err() {
336 finished = false; // workers gone (deadline) — walk didn't complete
337 break;
338 }
339 processed += 1;
340 if cap.is_some_and(|c| processed >= c) {
341 finished = false;
342 break;
343 }
344 }
345 drop(path_tx); // close → workers drain and exit
346 (seen, finished)
347 });
348
349 // parse workers: pull paths, parse (with the content pre-filter) in
350 // parallel, stream results out
351 let parse_incomplete = &parse_incomplete;
352 for _ in 0..workers {
353 let path_rx = Arc::clone(&path_rx);
354 let res_tx = res_tx.clone();
355 s.spawn(move || {
356 loop {
357 let got = { path_rx.lock().unwrap().recv() };
358 let Ok(path) = got else { break }; // channel closed
359 if past(deadline) || cancel.is_some_and(|c| c.load(Ordering::Relaxed)) {
360 parse_incomplete.store(true, Ordering::Relaxed); // backlog abandoned
361 break;
362 }
363 if let Some(fs) = parse_file(root, &path, needle)
364 && res_tx.send(fs).is_err()
365 {
366 break;
367 }
368 }
369 });
370 }
371 drop(res_tx); // the workers hold the live clones
372
373 // consumer (this thread): hand each parsed file to the sink as it arrives
374 while let Ok(fs) = res_rx.recv() {
375 sink(fs)?;
376 }
377 Ok(walk.join().unwrap())
378 })?;
379
380 Ok((
381 seen,
382 walk_finished && !parse_incomplete.load(Ordering::Relaxed),
383 ))
384}
385
386/// Decide an index sweep's outcome: whether to *finalize* (reconcile deletions +
387/// record the indexed HEAD) and the coverage `status` to store.
388///
389/// The guard (budgeted/warm passes only): a completed whole-repo warm that saw
390/// **zero** source files while the index already held some is almost certainly a
391/// failed enumeration (a `git ls-files` hiccup, a wrong root), not "every file
392/// was deleted". Finalizing it would forget the entire index and mark it
393/// `complete` — which warm-skip then strands at zero forever (a clean, "complete"
394/// repo isn't re-warmed). So it isn't finalized and stays `warming` for the next
395/// query to retry. An explicit `rq --index` (unbounded, `budgeted = false`) walks
396/// the filesystem and is user-initiated, so it's trusted: an empty tree really
397/// does reconcile the index away. A genuinely empty repo (nothing stored before)
398/// also completes.
399fn sweep_outcome(
400 completed: bool,
401 whole_repo: bool,
402 seen_empty: bool,
403 had_stored: bool,
404 budgeted: bool,
405) -> (bool, &'static str) {
406 if !whole_repo {
407 // a subtree index is a *seed* — it never reconciles (it didn't see the
408 // whole tree) and leaves coverage `warming` so normal warming carries
409 // on over the rest of the repo
410 return (false, "warming");
411 }
412 if budgeted && completed && seen_empty && had_stored {
413 return (false, "warming"); // suspicious empty warm — don't wipe the index
414 }
415 if completed {
416 (true, "complete")
417 } else {
418 (false, "warming")
419 }
420}
421
422/// The shared indexing core behind both the explicit (`index_under`) and
423/// opportunistic (`index_budgeted`) paths, run as a single fused pipeline: one
424/// walk thread streams candidate paths (cheap, stat-only, mtime-skipping
425/// unchanged files), a pool of parse workers turns them into symbols in parallel,
426/// and this thread writes the results in batches **as they arrive** — so a pass
427/// cut short by its budget still persists everything parsed up to that point, and
428/// indexing starts the instant the first file is found (walk and parse overlap).
429///
430/// `active` files are parsed first and ignore `budget` (the working set stays
431/// fresh); then the walk streams the rest in walk order. `subdirs` (empty = whole
432/// repo) scope the walk; `budget` bounds it (`None` = unbounded). A whole-repo
433/// sweep that finishes within budget reconciles deletions and is `complete`; a
434/// sweep cut short — or a subtree seed — is `warming`.
435fn run_index(
436 store: &mut Store,
437 root: &Path,
438 active: &[String],
439 subdirs: &[String],
440 budget: Option<Duration>,
441 query: Option<&str>,
442 cancel: Option<&std::sync::atomic::AtomicBool>,
443) -> Result<Stats, Box<dyn std::error::Error>> {
444 let identity = detect_identity(root);
445 let branch = git_output(root, &["rev-parse", "--abbrev-ref", "HEAD"]);
446 let repo_id = store.upsert_repository(&identity, branch.as_deref())?;
447 let root_display = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
448 store.upsert_checkout(repo_id, &root_display.to_string_lossy(), branch.as_deref())?;
449
450 // Registering the current root guarantees a live checkout, so prune any
451 // sibling rows whose path has since vanished (the repo moved) — keeps the
452 // identity→location map from accumulating dead bindings. Runs here, on index/
453 // warm, not on every search: stale rows are cheap (reads route around them),
454 // so occasional cleanup when we're already writing checkouts is enough.
455 for stale in store.checkout_roots(repo_id).unwrap_or_default() {
456 if !Path::new(&stale).exists() {
457 let _ = store.forget_checkout(&stale);
458 }
459 }
460
461 let stored = store.file_mtimes(repo_id)?;
462 let mut seen: HashSet<String> = HashSet::new();
463
464 // A cold, unbounded index (no prior coverage, the explicit `rq --index`)
465 // suspends per-row FTS maintenance and rebuilds the trigram index in one bulk
466 // pass at the end — the per-row trigger is ~70% of the write cost. Scoped to
467 // the cold full path so incremental re-index and budgeted warming (which may
468 // run concurrently and only touch a few files) keep the per-row trigger.
469 let bulk_fts = budget.is_none() && stored.is_empty();
470 if bulk_fts {
471 store.defer_fts_insert()?;
472 } else if store.fts_trigger_missing().unwrap_or(false) {
473 // A cold bulk index elsewhere dropped the trigger — either it crashed
474 // before its rebuild, or it's still running. Heal before writing more
475 // rows: the rebuild re-syncs FTS from the symbols table and restores
476 // the trigger (a live bulk then pays per-row cost for its remainder —
477 // rare overlap, and its own rebuild at the end is a harmless no-op).
478 let _ = store.rebuild_fts();
479 }
480
481 // Active (branch) files first: always parsed and written, so the working set
482 // stays fresh even when a tight budget cuts the walk short.
483 let mut active_to_parse: Vec<std::path::PathBuf> = Vec::new();
484 for rel in active {
485 note_candidate(
486 root,
487 &root.join(rel),
488 &stored,
489 &mut seen,
490 &mut active_to_parse,
491 );
492 }
493 let (active_parsed, _) = parse_files(root, &active_to_parse, None, None);
494 let (mut files_indexed, mut symbols) = store.replace_files(repo_id, &active_parsed)?;
495
496 // walk the whole repo, or just the requested subtrees — paths stay relative
497 // to `root` so they're repo-relative either way
498 let walk_roots: Vec<std::path::PathBuf> = if subdirs.is_empty() {
499 vec![root.to_path_buf()]
500 } else {
501 subdirs.iter().map(|s| root.join(s)).collect()
502 };
503
504 // Enumerate candidates. A budgeted (warming) pass on a git repo reads git's
505 // index — O(index read), no filesystem traversal — so a huge repo isn't stuck
506 // re-walking non-source trees every pass and never reaching source. An
507 // explicit unbounded index, or a non-git dir, walks the filesystem (thorough;
508 // catches untracked files). `git ls-files` runs *before* the deadline so its
509 // (cheap) work never eats the parse budget.
510 // An empty result means nothing is tracked yet (a fresh/uncommitted repo), so
511 // fall back to the filesystem walk, which sees untracked files.
512 let git_candidates = budget
513 .and_then(|_| git_source_candidates(root))
514 .filter(|paths| !paths.is_empty());
515 let candidates: Box<dyn Iterator<Item = std::path::PathBuf> + Send> = match git_candidates {
516 // parse query-relevant files (by path) first — a cheap in-memory reorder
517 Some(paths) => Box::new(prioritize_by_path(paths, root, query).into_iter()),
518 None => Box::new(fs_walk_candidates(walk_roots)),
519 };
520
521 let deadline = budget.map(|b| Instant::now() + b);
522 let cap = budget.map(|_| collect_cap());
523
524 // Fused walk → parse → write: stream candidates through the shared pipeline,
525 // committing parsed files in batches as they arrive (so a budget-cut or killed
526 // pass keeps what it parsed). Only new or changed files are parsed; every
527 // source file seen lands in `seen` for deletion reconcile.
528 let stored_ref = &stored;
529 let keep = |rel: &str, path: &Path| match stored_ref.get(rel) {
530 Some(&Some(m)) => Some(m) != file_mtime(path),
531 _ => true, // new file, or one stored without an mtime
532 };
533 let stream_start = Instant::now();
534 let (seen, completed, walk_files, walk_symbols, write_time) = {
535 let mut writer = BatchWriter::new(&mut *store, repo_id);
536 let (seen, completed) = stream_walk(
537 root,
538 candidates,
539 deadline,
540 cap,
541 None,
542 seen,
543 keep,
544 cancel,
545 |fs| writer.push(fs),
546 )?;
547 writer.flush()?;
548 (
549 seen,
550 completed,
551 writer.files,
552 writer.symbols,
553 writer.write_time,
554 )
555 };
556 if crate::trace::enabled() {
557 let elapsed = stream_start.elapsed();
558 crate::trace!(
559 "walk+parse+write {} file(s)/{} symbol(s) in {} ms ({} ms in store writes, {} parse jobs)",
560 walk_files,
561 walk_symbols,
562 elapsed.as_millis(),
563 write_time.as_millis(),
564 parse_jobs(),
565 );
566 }
567 if bulk_fts {
568 let t = crate::trace::Timer::start("fts bulk rebuild");
569 store.rebuild_fts()?;
570 drop(t);
571 }
572 files_indexed += walk_files;
573 symbols += walk_symbols;
574 let stats = Stats {
575 files_seen: seen.len(),
576 files_indexed,
577 symbols,
578 };
579
580 let whole_repo = subdirs.is_empty();
581 let (finalize, status) = sweep_outcome(
582 completed,
583 whole_repo,
584 seen.is_empty(),
585 !stored.is_empty(),
586 budget.is_some(),
587 );
588 // a finalized whole-repo sweep saw every live file → anything still indexed
589 // (but not seen) was deleted on disk. A sweep that saw *zero* files while the
590 // index held some is treated as a failed enumeration (see `sweep_outcome`),
591 // not finalized — so a transient empty walk can't wipe a populated index.
592 if finalize {
593 let mut forgotten = 0;
594 for path in stored.keys() {
595 if !seen.contains(path) {
596 store.forget_file(repo_id, path)?;
597 forgotten += 1;
598 }
599 }
600 if forgotten > 0 {
601 crate::trace!(
602 "reconcile {}: forgot {forgotten} file(s) not seen on disk",
603 crate::trace::abbrev(&root_display)
604 );
605 }
606 // record the commit the index now reflects, so a later search can detect
607 // an unchanged committed tree and skip re-walking a large repo
608 if let Some(head) = git_head(root) {
609 let _ = store.set_indexed_head(repo_id, &head);
610 }
611 }
612 // commit times feed the recency signal, but `git log -n1000 --name-only` is
613 // pricey on a big repo. Run it only when this run indexed something AND
614 // `root` is the work-tree root: a subdir index's `git log` walks the whole
615 // repo's history yet emits repo-relative paths that wouldn't match our
616 // subdir-relative ones — pure waste. (A subdir index leans on mtime recency.)
617 if stats.files_indexed > 0 && repo_root(root).is_some_and(|r| r == root_display) {
618 capture_commit_times(store, repo_id, root);
619 }
620
621 // Never persist "complete" for an empty index: a zero-file complete is almost
622 // by definition wrong (a failed enumeration), and warm-skip would then strand
623 // the repo at zero. Keep it "warming" so the next query keeps polling for
624 // files to index. Counts the repo's *total* indexed files, not this run's —
625 // a warm of an already-indexed repo re-parses nothing yet isn't empty.
626 let total_files = store.repo_totals(repo_id).map(|(f, _)| f).unwrap_or(0);
627 let status = if status == "complete" && total_files == 0 {
628 "warming"
629 } else {
630 status
631 };
632 store.set_coverage(
633 repo_id,
634 stats.files_seen as i64,
635 stats.files_indexed as i64,
636 status,
637 )?;
638 crate::trace!(
639 "index {} (budget {budget:?}): {} seen, {} indexed, {} symbols → {status}",
640 crate::trace::abbrev(&root_display),
641 stats.files_seen,
642 stats.files_indexed,
643 stats.symbols,
644 );
645 Ok(stats)
646}
647
648/// Note a walked file: record every source file in `seen` (for deletion
649/// reconcile), and queue it for parsing only when it's new or its mtime moved —
650/// a cheap `stat` skips unchanged files before any read. Non-source files are
651/// ignored entirely.
652fn note_candidate(
653 root: &Path,
654 file: &Path,
655 stored: &HashMap<String, Option<i64>>,
656 seen: &mut HashSet<String>,
657 to_parse: &mut Vec<std::path::PathBuf>,
658) {
659 let Some(ext) = file.extension().and_then(|e| e.to_str()) else {
660 return;
661 };
662 if lang::plugin_for_extension(ext).is_none() {
663 return;
664 }
665 let rel = file
666 .strip_prefix(root)
667 .unwrap_or(file)
668 .to_string_lossy()
669 .into_owned();
670 if !seen.insert(rel.clone()) {
671 return; // already noted (e.g. an active file re-seen by the walk)
672 }
673 // unchanged by mtime → already indexed, no need to re-parse
674 if let Some(&Some(m)) = stored.get(&rel)
675 && Some(m) == file_mtime(file)
676 {
677 return;
678 }
679 to_parse.push(file.to_path_buf());
680}
681
682/// Read + parse one source file into a [`FileSymbols`], or `None` if it isn't a
683/// known language, can't be read, or (when `needle` is set) doesn't contain the
684/// query — the ripgrep-style content pre-filter, applied here so it runs on the
685/// worker thread. Touches no store — safe to run in parallel (each call builds
686/// its own Tree-sitter parser).
687fn parse_file(
688 root: &Path,
689 file: &Path,
690 needle: Option<&[u8]>,
691) -> Option<crate::store::FileSymbols> {
692 let ext = file.extension().and_then(|e| e.to_str())?;
693 let plugin = lang::plugin_for_extension(ext)?;
694 let rel = file
695 .strip_prefix(root)
696 .unwrap_or(file)
697 .to_string_lossy()
698 .into_owned();
699 let source = std::fs::read_to_string(file).ok()?;
700 // pre-filter: skip the expensive parse on files that can't hold the match
701 if let Some(n) = needle
702 && !contains_ascii_ci(source.as_bytes(), n)
703 {
704 return None;
705 }
706 let content_hash = content_hash(&source);
707 let symbols = plugin.extract(&rel, &source);
708 Some(crate::store::FileSymbols {
709 path: rel,
710 language: plugin.language().to_string(),
711 mtime: file_mtime(file),
712 content_hash,
713 symbols,
714 })
715}
716
717/// Whether an optional deadline has passed (always false when unbounded).
718fn past(deadline: Option<Instant>) -> bool {
719 deadline.is_some_and(|d| Instant::now() >= d)
720}
721
722/// Parse many files across the available CPUs, stopping early once `deadline`
723/// passes; when `needle` is set, each worker skips files that don't contain it
724/// (the content pre-filter). Returns the parsed files and whether *all* of them
725/// were parsed (false if the deadline cut it short). Parsing is the expensive,
726/// CPU-bound step; writing stays serialized in one batched transaction by the
727/// caller.
728fn parse_files(
729 root: &Path,
730 paths: &[std::path::PathBuf],
731 deadline: Option<Instant>,
732 needle: Option<&[u8]>,
733) -> (Vec<crate::store::FileSymbols>, bool) {
734 use std::sync::atomic::{AtomicBool, Ordering};
735
736 let workers = parse_jobs().min(paths.len());
737
738 if workers <= 1 {
739 let mut out = Vec::new();
740 for p in paths {
741 if past(deadline) {
742 return (out, false);
743 }
744 if let Some(parsed) = parse_file(root, p, needle) {
745 out.push(parsed);
746 }
747 }
748 return (out, true);
749 }
750
751 let bailed = AtomicBool::new(false);
752 let chunk_size = paths.len().div_ceil(workers);
753 let mut out = Vec::new();
754 std::thread::scope(|s| {
755 let handles: Vec<_> = paths
756 .chunks(chunk_size)
757 .map(|chunk| {
758 let bailed = &bailed;
759 s.spawn(move || {
760 let mut local = Vec::new();
761 for p in chunk {
762 if past(deadline) {
763 bailed.store(true, Ordering::Relaxed);
764 break;
765 }
766 if let Some(parsed) = parse_file(root, p, needle) {
767 local.push(parsed);
768 }
769 }
770 local
771 })
772 })
773 .collect();
774 for h in handles {
775 out.extend(h.join().unwrap_or_default());
776 }
777 });
778 (out, !bailed.load(Ordering::Relaxed))
779}
780
781/// Capture per-file last-commit times for the recency signal — incrementally.
782/// The full history walk is priced only once: after a capture, the HEAD it ran
783/// at is recorded, so the next capture reads just the commits since
784/// (`old..HEAD`) — and skips the `git log` entirely when HEAD hasn't moved
785/// (the common case for a warm of uncommitted edits, which mtime already
786/// covers). A vanished old sha (rebase, gc) fails the range and falls back to
787/// the full bounded walk.
788fn capture_commit_times(store: &mut Store, repo_id: i64, root: &Path) {
789 let Some(head) = git_head(root) else { return };
790 let last = store.git_ts_head(repo_id).ok().flatten();
791 if last.as_deref() == Some(head.as_str()) {
792 return; // HEAD unmoved — nothing new to capture
793 }
794 let first = last.is_none();
795 let times = last
796 .and_then(|old| git_commit_times_range(root, &old, 1000))
797 .unwrap_or_else(|| git_commit_times(root, 1000));
798 if !times.is_empty() {
799 if store.set_file_git_ts(repo_id, ×).is_err() {
800 return; // don't advance the marker past an unpersisted capture
801 }
802 } else if first {
803 return; // full walk yielded nothing — leave the marker unset to retry
804 }
805 let _ = store.set_git_ts_head(repo_id, &head);
806}
807
808/// Map of repo-relative path → most-recent commit time (unix seconds), from the
809/// last `limit` commits. Paths are repo-root-relative, matching the indexed
810/// paths when `root` is the repository root.
811fn git_commit_times(root: &Path, limit: usize) -> HashMap<String, i64> {
812 match git_output(
813 root,
814 &[
815 "log",
816 &format!("-n{limit}"),
817 "--name-only",
818 "--pretty=format:%ct",
819 ],
820 ) {
821 Some(text) => parse_git_log(&text),
822 None => HashMap::new(),
823 }
824}
825
826/// Like [`git_commit_times`], limited to the commits in `old..HEAD`. `None`
827/// when the range can't be resolved (`old` no longer exists) *or* is empty —
828/// an empty range only arises from a backwards HEAD move (reset/checkout), and
829/// the full-walk fallback re-captures correct times for it.
830fn git_commit_times_range(root: &Path, old: &str, limit: usize) -> Option<HashMap<String, i64>> {
831 git_output(
832 root,
833 &[
834 "log",
835 &format!("-n{limit}"),
836 "--name-only",
837 "--pretty=format:%ct",
838 &format!("{old}..HEAD"),
839 ],
840 )
841 .map(|text| parse_git_log(&text))
842}
843
844/// Parse `git log --name-only --pretty=format:%ct` output into path → latest
845/// commit time. Newest-first, so the first time a path appears is its most
846/// recent commit.
847fn parse_git_log(text: &str) -> HashMap<String, i64> {
848 let mut map = HashMap::new();
849 let mut current_ts = 0i64;
850 for line in text.lines() {
851 if line.is_empty() {
852 continue;
853 }
854 if let Ok(ts) = line.parse::<i64>() {
855 // a commit-timestamp header (filenames that are pure integers don't
856 // occur in practice)
857 current_ts = ts;
858 } else {
859 map.entry(line.to_string()).or_insert(current_ts);
860 }
861 }
862 map
863}
864
865/// Live, budgeted scan (search Layer 4): stream-walk `root` on the same fused
866/// [`stream_walk`] engine as the indexer, parsing source files and returning the
867/// parsed `FileSymbols` *without* touching the store — so `rq` answers at zero
868/// coverage. Bounded and filtered:
869/// - stop once `deadline` passes;
870/// - skip any file whose repo-relative path is in `skip` (already indexed);
871/// - when `needle` is set, parse only files containing it (case-insensitive
872/// substring) — the ripgrep-style pre-filter that skips the tree-sitter parse
873/// on files that can't hold an exact/prefix/substring match. `needle` is `None`
874/// for the *fuzzy* fallback: an abbreviation (`usr` → `user`) isn't a substring
875/// of its match, so it can't be content-filtered; callers retry unfiltered when
876/// a filtered scan comes up empty.
877///
878/// The caller decides the fate of the result, which is exactly where the
879/// persist-or-not policy lives: a warming git repo **persists** them via
880/// `replace_files` (folds the scan into the index — demand-first coverage); a
881/// non-git dir ranks them in-memory and discards them (there's no index to fold
882/// into). Streaming — never collect-then-parse — keeps a scan too big to finish
883/// from coming up empty.
884pub fn scan(
885 root: &Path,
886 skip: &HashSet<String>,
887 deadline: Option<Instant>,
888 needle: Option<&[u8]>,
889) -> Vec<crate::store::FileSymbols> {
890 let needle = needle.filter(|n| !n.is_empty());
891 // git's index for a git repo (content-scan a huge repo without traversing it),
892 // else a filesystem walk (the live scan of a non-git dir)
893 let candidates: Box<dyn Iterator<Item = std::path::PathBuf> + Send> =
894 match git_source_candidates(root).filter(|paths| !paths.is_empty()) {
895 Some(paths) => Box::new(paths.into_iter()),
896 None => Box::new(fs_walk_candidates(vec![root.to_path_buf()])),
897 };
898 let mut out: Vec<crate::store::FileSymbols> = Vec::new();
899 let keep = |rel: &str, _: &Path| !skip.contains(rel); // skip already-indexed
900 let _ = stream_walk(
901 root,
902 candidates,
903 deadline,
904 None,
905 needle,
906 HashSet::new(),
907 keep,
908 None,
909 |fs| {
910 out.push(fs);
911 Ok(())
912 },
913 );
914 out
915}
916
917/// Case-insensitive (ASCII) substring test — `haystack` contains `needle`.
918/// Allocation-free; used to pre-filter live-scan files before parsing.
919fn contains_ascii_ci(haystack: &[u8], needle: &[u8]) -> bool {
920 if needle.len() > haystack.len() {
921 return false;
922 }
923 haystack
924 .windows(needle.len())
925 .any(|w| w.eq_ignore_ascii_case(needle))
926}
927
928/// Result of revalidating a single file against what's on disk.
929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
930pub enum Refresh {
931 /// Nothing to do — content hash still matches, or the file couldn't be read
932 /// right now (left in place rather than forgotten — see [`refresh_file`]).
933 Unchanged,
934 /// File changed; its symbols were re-extracted.
935 Updated,
936}
937
938/// Whether `root` is inside a git work tree. Implicit (opportunistic) indexing
939/// is gated on this so a stray query never walks a non-repo directory. Native
940/// (no `git` fork) — it runs on every search.
941pub fn is_git_repo(root: &Path) -> bool {
942 repo_root(root).is_some()
943}
944
945/// The git work-tree root at or above `path` — the nearest ancestor holding a
946/// `.git` entry — found without shelling out. `.git` may be a directory or a
947/// file (worktrees, submodules), so we test existence either way. `None` when
948/// `path` is not inside a work tree.
949pub fn repo_root(path: &Path) -> Option<std::path::PathBuf> {
950 let start = path.canonicalize().ok()?;
951 start
952 .ancestors()
953 .find(|a| a.join(".git").exists())
954 .map(Path::to_path_buf)
955}
956
957/// The current HEAD commit sha, or `None` outside a git work tree.
958pub fn git_head(root: &Path) -> Option<String> {
959 git_output(root, &["rev-parse", "HEAD"])
960}
961
962/// Whether the work tree has uncommitted changes to *tracked* files (staged or
963/// unstaged). `--untracked-files=no` skips the work-tree-wide untracked-file
964/// scan — the expensive, cold-cache-sensitive part of `git status` on a large
965/// repo (it walks to classify every path against `.gitignore`). This runs on
966/// every search to gate warming, so the scan dominated query-time variance.
967///
968/// The tradeoff: a brand-new *untracked* file isn't seen as a change here, so it
969/// won't be picked up by the opportunistic warm until it's committed (HEAD moves
970/// → warm) or `rq --index`ed. Tracked edits, the common case, are still caught,
971/// and `git status` still refreshes the index so a touched-but-unchanged file
972/// doesn't read as dirty. Empty stdout (clean) reports as `None` via
973/// `git_output`.
974pub fn is_dirty(root: &Path) -> bool {
975 git_output(root, &["status", "--porcelain", "--untracked-files=no"]).is_some()
976}
977
978/// Repo-relative files you're working on this branch: committed changes since
979/// the branch diverged from the trunk, plus uncommitted edits. Empty on the
980/// trunk itself (where it isn't a useful signal) or outside git. Feeds the
981/// branch ranking boost — necessarily a few git calls, but gated to feature
982/// branches.
983pub fn branch_changed_files(root: &Path) -> Vec<String> {
984 let Some(branch) = git_output(root, &["rev-parse", "--abbrev-ref", "HEAD"]) else {
985 return Vec::new();
986 };
987 if is_trunk(&branch) {
988 return Vec::new();
989 }
990 let Some(trunk) = trunk_ref(root) else {
991 return Vec::new();
992 };
993
994 let mut files: HashMap<String, ()> = HashMap::new();
995 // committed branch changes since divergence from the trunk (three-dot)
996 if let Some(out) = git_output(root, &["diff", "--name-only", &format!("{trunk}...HEAD")]) {
997 files.extend(
998 out.lines()
999 .filter(|l| !l.is_empty())
1000 .map(|l| (l.to_string(), ())),
1001 );
1002 }
1003 // uncommitted edits to tracked files
1004 if let Some(out) = git_output(root, &["diff", "--name-only", "HEAD"]) {
1005 files.extend(
1006 out.lines()
1007 .filter(|l| !l.is_empty())
1008 .map(|l| (l.to_string(), ())),
1009 );
1010 }
1011 files.into_keys().collect()
1012}
1013
1014/// Branch names treated as the trunk — the "active files" signal doesn't apply
1015/// there (you're not on a feature branch).
1016fn is_trunk(branch: &str) -> bool {
1017 matches!(branch, "main" | "master" | "trunk")
1018}
1019
1020/// The trunk ref to diff against: `main` if it exists, else `master`.
1021fn trunk_ref(root: &Path) -> Option<String> {
1022 ["main", "master"]
1023 .into_iter()
1024 .find(|name| git_output(root, &["rev-parse", "--verify", "--quiet", name]).is_some())
1025 .map(str::to_string)
1026}
1027
1028/// Lazily revalidate one indexed file against disk: re-extract it if its content
1029/// changed. This is the staleness check search runs over its top results.
1030///
1031/// It deliberately **never forgets** a file: a failed read isn't proof of
1032/// deletion (a wrong checkout root, a transient FS error, or a race all look the
1033/// same), and a search must never destroy index data over it — that bug dropped
1034/// whole indexes when a stale checkout root made every read fail. Genuine
1035/// deletions are reconciled by an indexing pass ([`run_index`]), which sees the
1036/// whole tree at once and can tell "gone" from "couldn't read one file".
1037pub fn refresh_file(
1038 store: &mut Store,
1039 repository_id: i64,
1040 root: &Path,
1041 rel: &str,
1042) -> Result<Refresh, Box<dyn std::error::Error>> {
1043 let path = root.join(rel);
1044 let source = match std::fs::read_to_string(&path) {
1045 Ok(s) => s,
1046 Err(_) => return Ok(Refresh::Unchanged), // unreadable now — leave it, don't forget
1047 };
1048 let hash = content_hash(&source);
1049 if store.file_unchanged(repository_id, rel, &hash)? {
1050 return Ok(Refresh::Unchanged);
1051 }
1052 let ext = path
1053 .extension()
1054 .and_then(|e| e.to_str())
1055 .unwrap_or_default();
1056 let plugin = lang::plugin_for_extension(ext);
1057 let symbols = match plugin {
1058 Some(plugin) => plugin.extract(rel, &source),
1059 None => Vec::new(),
1060 };
1061 // the plugin knows its language even when a file parses to zero symbols
1062 let language = plugin.map_or("unknown", |p| p.language());
1063 let mtime = file_mtime(&path);
1064 store.replace_file_symbols(repository_id, rel, language, mtime, &hash, &symbols)?;
1065 Ok(Refresh::Updated)
1066}
1067
1068/// Best-effort repository identity: upstream git remote, else the local path.
1069pub fn detect_identity(root: &Path) -> RepoIdentity {
1070 for remote in ["origin", "upstream"] {
1071 if let Some(url) = git_output(root, &["remote", "get-url", remote])
1072 && let Some(id) = RepoIdentity::from_remote_url(&url)
1073 {
1074 return id;
1075 }
1076 }
1077 let abs = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1078 RepoIdentity::local(&abs.to_string_lossy())
1079}
1080
1081/// Run a git command in `root`, returning trimmed stdout on success.
1082fn git_output(root: &Path, args: &[&str]) -> Option<String> {
1083 let out = Command::new("git")
1084 .arg("-C")
1085 .arg(root)
1086 .args(args)
1087 .output()
1088 .ok()?;
1089 if !out.status.success() {
1090 return None;
1091 }
1092 let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
1093 if s.is_empty() { None } else { Some(s) }
1094}
1095
1096fn content_hash(source: &str) -> String {
1097 // DefaultHasher uses fixed keys, so this is stable across runs — enough for
1098 // change detection (not cryptographic).
1099 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1100 source.hash(&mut hasher);
1101 format!("{:016x}", hasher.finish())
1102}
1103
1104fn file_mtime(path: &Path) -> Option<i64> {
1105 let modified = std::fs::metadata(path).ok()?.modified().ok()?;
1106 // nanosecond resolution (like git's racy-mtime handling): two edits within
1107 // the same second still get distinct mtimes, so an index taken between them
1108 // can't mistake the second edit for "unchanged". Fits i64 until 2262.
1109 let nanos = modified.duration_since(UNIX_EPOCH).ok()?.as_nanos();
1110 Some(nanos as i64)
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115 use super::*;
1116
1117 #[test]
1118 fn sweep_outcome_guards_against_a_failed_empty_walk() {
1119 // normal warm: completed whole-repo sweep finalizes and completes
1120 assert_eq!(
1121 sweep_outcome(true, true, false, true, true),
1122 (true, "complete")
1123 );
1124 // a genuinely empty repo (nothing stored before) still completes
1125 assert_eq!(
1126 sweep_outcome(true, true, true, false, true),
1127 (true, "complete")
1128 );
1129 // THE GUARD (warm only): completed but saw zero files while the index
1130 // held some → don't finalize (don't wipe), stay warming to retry
1131 assert_eq!(
1132 sweep_outcome(true, true, true, true, true),
1133 (false, "warming")
1134 );
1135 // an explicit `--index` (unbounded) is trusted: an empty tree reconciles
1136 assert_eq!(
1137 sweep_outcome(true, true, true, true, false),
1138 (true, "complete")
1139 );
1140 // a budget-cut sweep stays warming and doesn't reconcile
1141 assert_eq!(
1142 sweep_outcome(false, true, false, true, true),
1143 (false, "warming")
1144 );
1145 // a subtree index is a seed: never reconciles, and leaves coverage
1146 // warming so later queries keep indexing the rest of the repo
1147 assert_eq!(
1148 sweep_outcome(true, false, false, true, true),
1149 (false, "warming")
1150 );
1151 }
1152
1153 #[test]
1154 fn content_hash_is_stable_and_distinguishes() {
1155 assert_eq!(
1156 content_hash("class Foo\nend"),
1157 content_hash("class Foo\nend")
1158 );
1159 assert_ne!(
1160 content_hash("class Foo\nend"),
1161 content_hash("class Bar\nend")
1162 );
1163 }
1164
1165 #[test]
1166 fn trunk_names_are_recognized() {
1167 assert!(is_trunk("main"));
1168 assert!(is_trunk("master"));
1169 assert!(!is_trunk("feature/x"));
1170 assert!(!is_trunk("dpep/fix"));
1171 }
1172
1173 #[test]
1174 fn prioritize_by_path_is_loose_but_targeted() {
1175 let root = Path::new("/repo");
1176 let paths: Vec<std::path::PathBuf> = [
1177 "companies.rb", // unrelated → tail
1178 "app/employee.rb", // near-match → front
1179 "lib/EmpController.rb", // near-match (shares "cont…") → front
1180 "employers.rb", // near-match (shares "employe") → front
1181 "app/controllers/x.rb", // dir matches but stem doesn't → tail
1182 ]
1183 .iter()
1184 .map(|p| root.join(p))
1185 .collect();
1186 let out = prioritize_by_path(paths.clone(), root, Some("employeescontroller"));
1187 let name = |p: &std::path::PathBuf| p.file_name().unwrap().to_str().unwrap().to_string();
1188 let front: Vec<String> = out[..3].iter().map(name).collect();
1189 assert!(front.contains(&"employee.rb".to_string()), "{front:?}");
1190 assert!(front.contains(&"EmpController.rb".to_string()), "{front:?}");
1191 assert!(front.contains(&"employers.rb".to_string()), "{front:?}");
1192 let tail: Vec<String> = out[3..].iter().map(name).collect();
1193 assert!(tail.contains(&"companies.rb".to_string()), "{tail:?}");
1194 assert!(tail.contains(&"x.rb".to_string()), "{tail:?}"); // dir match isn't enough
1195 // no query → unchanged
1196 assert_eq!(prioritize_by_path(paths.clone(), root, None), paths);
1197 }
1198
1199 #[test]
1200 fn detects_git_work_tree_natively() {
1201 let dir = std::env::temp_dir().join(format!("rq-reporoot-{}", std::process::id()));
1202 let _ = std::fs::remove_dir_all(&dir);
1203 std::fs::create_dir_all(dir.join("sub")).unwrap();
1204
1205 assert!(!is_git_repo(&dir), "no .git yet");
1206 std::fs::create_dir_all(dir.join(".git")).unwrap();
1207 assert!(is_git_repo(&dir), "a .git entry marks a work tree");
1208 // from a subdirectory, repo_root walks up to the work-tree root
1209 assert_eq!(
1210 repo_root(&dir.join("sub")).unwrap(),
1211 dir.canonicalize().unwrap()
1212 );
1213
1214 let _ = std::fs::remove_dir_all(&dir);
1215 }
1216
1217 #[test]
1218 fn parses_git_log_keeping_most_recent_commit_per_file() {
1219 // newest-first: a.rb appears in both commits; the newer ts wins
1220 let log = "1700000000\n\na.rb\nb.rb\n1699990000\n\na.rb\nc.rb\n";
1221 let map = parse_git_log(log);
1222 assert_eq!(map.get("a.rb"), Some(&1700000000));
1223 assert_eq!(map.get("b.rb"), Some(&1700000000));
1224 assert_eq!(map.get("c.rb"), Some(&1699990000));
1225 assert_eq!(map.len(), 3);
1226 }
1227}