nornir 0.5.2

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
//! File-level **incremental** introspection — the JetBrains-beating delta lever.
//!
//! A full [`symbols::scan_repo`] re-parses every `.rs` in a member on any change
//! and re-emits the member's whole row set (design doc §2, `P0.1`). JetBrains
//! wins re-index latency by re-indexing only the *edited* file. This module
//! closes that gap: it keeps a per-file **fingerprint manifest** (`mtime + size`
//! cheap gate → `sha2` content hash confirm) alongside the last scan, and on a
//! rebuild it
//!
//!   1. diffs the working tree against the manifest → an [`DeltaPlan`]
//!      (`added` / `changed` / `removed` / `renamed`),
//!   2. **evicts** every row of a changed-or-removed file (correctness: a
//!      removed symbol must disappear from the warehouse),
//!   3. **re-parses only** the added-or-changed files ([`symbols::scan_selected_files`]),
//!   4. **merges** those fresh rows back in,
//!
//! so a one-line edit in an N-file member costs one `syn::parse_file`, not N.
//!
//! ## Efficiency (design doc §"zero-copy/zero-alloc where practical")
//! - **No read of unchanged files.** [`Manifest::refresh`] reuses the previous
//!   content hash whenever `(mtime, size)` are unchanged — a `stat`, no `read`,
//!   no re-hash. Only files whose `(mtime, size)` moved are read + re-hashed.
//! - **Eviction is in-place** (`Vec::retain`), **merge is a move** (`Vec::append`)
//!   — no per-row clone on the hot path.
//! - **Renames reuse the parse.** A rename (a `removed` path and an `added` path
//!   with byte-identical content) is served by *relabelling* the existing rows'
//!   `file` column — the file is never re-parsed and no symbol is re-derived.
//! - The parse stays `syn`-incremental at **file granularity** (one
//!   `syn::parse_file` per changed file, via [`symbols::scan_selected_files`]).
//!
//! ## SCIP
//! The resolved (rust-analyzer/SCIP) `ScipRow` carries the same repo-relative
//! `path`, so the identical evict-by-`file` / merge-by-`file` machinery applies
//! to a `ScipScan` unchanged — SCIP occurrences for unchanged files are **reused,
//! not re-derived**. Those tables are still populate-gated (design doc §4), so
//! this module deltas the populated syntactic (`syn`) layer today; wiring the
//! resolved layer through the same [`SymbolDelta::evict_file`] key is future work
//! noted in the design doc.
//!
//! The pure differ ([`diff`]) is decoupled from all I/O so it unit-tests over
//! hand-built manifests; [`Manifest::refresh`] is the I/O front that builds the
//! new manifest (reusing old hashes) and calls it.

use std::collections::BTreeMap;
use std::path::Path;
use std::time::UNIX_EPOCH;

use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use uuid::Uuid;

use super::symbols::{self, DiscoveredFile, SymbolScan};

/// A cheap-gate + content fingerprint of one source file.
///
/// `(mtime_ns, size)` is the *cheap gate*: if both are unchanged the file is
/// assumed unchanged and its `hash` is carried forward with **no read**. `hash`
/// (a `sha2` digest) is the *confirm*: a `touch` (mtime bumps, bytes identical)
/// re-hashes but then compares equal, so it is correctly a no-op, and a real
/// edit differs. Serializable so a manifest survives a server restart.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FileFingerprint {
    /// Nanoseconds since the Unix epoch of the file's mtime (`i128` so it never
    /// overflows and pre-epoch times stay ordered). `0` when the mtime is
    /// unavailable — which forces a content read on the next refresh (safe).
    pub mtime_ns: i128,
    pub size: u64,
    /// The `sha2` content digest. `#[serde]` handles `[u8; 32]` directly.
    pub hash: [u8; 32],
}

/// The per-file fingerprint manifest of one member at a warehouse snapshot —
/// keyed by the **repo-root-relative path** (identical to a scan row's `file`
/// column, so eviction keys line up). This IS the last-snapshot state a rebuild
/// diffs against.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct Manifest {
    pub files: BTreeMap<String, FileFingerprint>,
}

/// The result of diffing a working tree against a [`Manifest`]: which files the
/// rebuild must act on. `renamed` is `(old_rel, new_rel)` for a moved file whose
/// content is byte-identical — served by relabel, so it is intentionally **not**
/// in `added`/`removed`/`changed`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DeltaPlan {
    pub added: Vec<String>,
    pub changed: Vec<String>,
    pub removed: Vec<String>,
    pub renamed: Vec<(String, String)>,
}

impl DeltaPlan {
    /// True iff nothing changed — the rebuild can short-circuit entirely.
    pub fn is_empty(&self) -> bool {
        self.added.is_empty()
            && self.changed.is_empty()
            && self.removed.is_empty()
            && self.renamed.is_empty()
    }

    /// How many files the rebuild touches (parses or relabels) — the delta's
    /// "work size", the thing that should stay ~1 for a one-file edit.
    pub fn touched(&self) -> usize {
        self.added.len() + self.changed.len() + self.removed.len() + self.renamed.len()
    }

    /// The files that must be **re-parsed** (`added ∪ changed`). `removed` need
    /// only eviction; `renamed` is served by relabel — neither is re-parsed.
    fn to_reparse(&self) -> impl Iterator<Item = &String> {
        self.added.iter().chain(self.changed.iter())
    }

    /// The files whose existing rows must be **evicted** (`changed ∪ removed`).
    /// A `changed` file is evicted *then* re-parsed; `renamed` is relabelled, not
    /// evicted (evicting it would drop rows the relabel needs).
    fn to_evict(&self) -> impl Iterator<Item = &String> {
        self.changed.iter().chain(self.removed.iter())
    }
}

/// The **pure** file-level differ (no I/O): compare two fingerprint manifests.
///
/// A key in `new` but not `old` is `added`; in `old` but not `new` is `removed`;
/// in both with a differing `hash` is `changed`. Then **rename detection**: an
/// `added` path whose content hash equals a `removed` path's hash is a move —
/// pulled out of `added`/`removed` into `renamed` so the caller can relabel
/// instead of re-parsing. (If several files share content, pairs are matched
/// deterministically in sorted order.)
pub fn diff(old: &Manifest, new: &Manifest) -> DeltaPlan {
    let mut plan = DeltaPlan::default();

    for (path, nf) in &new.files {
        match old.files.get(path) {
            None => plan.added.push(path.clone()),
            Some(of) if of.hash != nf.hash => plan.changed.push(path.clone()),
            Some(_) => {} // unchanged
        }
    }
    for path in old.files.keys() {
        if !new.files.contains_key(path) {
            plan.removed.push(path.clone());
        }
    }

    detect_renames(&mut plan, old, new);
    plan
}

/// Reclassify `(removed, added)` pairs with byte-identical content as `renamed`.
/// Deterministic: candidates are consumed in the sorted order `diff` produced.
fn detect_renames(plan: &mut DeltaPlan, old: &Manifest, new: &Manifest) {
    if plan.added.is_empty() || plan.removed.is_empty() {
        return;
    }
    // hash → queue of removed paths carrying it (usually exactly one).
    let mut removed_by_hash: BTreeMap<[u8; 32], Vec<String>> = BTreeMap::new();
    for r in &plan.removed {
        if let Some(f) = old.files.get(r) {
            removed_by_hash.entry(f.hash).or_default().push(r.clone());
        }
    }
    let mut consumed_removed: Vec<String> = Vec::new();
    let mut still_added: Vec<String> = Vec::new();
    for a in std::mem::take(&mut plan.added) {
        let hash = new.files.get(&a).map(|f| f.hash);
        let matched = hash
            .and_then(|h| removed_by_hash.get_mut(&h))
            .and_then(|q| if q.is_empty() { None } else { Some(q.remove(0)) });
        match matched {
            Some(old_path) => {
                consumed_removed.push(old_path.clone());
                plan.renamed.push((old_path, a));
            }
            None => still_added.push(a),
        }
    }
    plan.added = still_added;
    plan.removed.retain(|r| !consumed_removed.contains(r));
}

impl Manifest {
    /// Build a fresh manifest by reading + hashing **every** discovered file (the
    /// full-build path). Equivalent to `refresh` against an empty manifest, but
    /// spelled out for the initial snapshot.
    pub fn build(repo_root: &Path, files: &[DiscoveredFile]) -> Result<Self> {
        Ok(Self::refresh_inner(repo_root, files, None)?.0)
    }

    /// Refresh `self` against the current working tree: produce the new manifest
    /// **and** the [`DeltaPlan`] in one pass, reading + hashing **only** files
    /// whose `(mtime, size)` moved (unchanged files carry their old hash forward
    /// with a bare `stat`). This is the delta hot path's I/O core.
    pub fn refresh(&self, repo_root: &Path, files: &[DiscoveredFile]) -> Result<(Manifest, DeltaPlan)> {
        Self::refresh_inner(repo_root, files, Some(self))
    }

    fn refresh_inner(
        repo_root: &Path,
        files: &[DiscoveredFile],
        old: Option<&Manifest>,
    ) -> Result<(Manifest, DeltaPlan)> {
        let mut new = Manifest::default();
        for f in files {
            let rel = symbols::rel_path(repo_root, &f.path);
            let meta = std::fs::metadata(&f.path)
                .with_context(|| format!("stat {}", f.path.display()))?;
            let size = meta.len();
            let mtime_ns = meta
                .modified()
                .ok()
                .and_then(|m| m.duration_since(UNIX_EPOCH).ok())
                .map(|d| d.as_nanos() as i128)
                .unwrap_or(0);

            // Cheap gate: unchanged (mtime,size) ⇒ reuse the old hash, NO read.
            let reused = old
                .and_then(|o| o.files.get(&rel))
                .filter(|of| of.mtime_ns == mtime_ns && of.size == size && mtime_ns != 0);
            let fp = match reused {
                Some(of) => of.clone(),
                None => {
                    let bytes = std::fs::read(&f.path)
                        .with_context(|| format!("read {}", f.path.display()))?;
                    let mut hasher = Sha256::new();
                    hasher.update(&bytes);
                    FileFingerprint { mtime_ns, size, hash: hasher.finalize().into() }
                }
            };
            new.files.insert(rel, fp);
        }
        let plan = diff(old.unwrap_or(&EMPTY_MANIFEST), &new);
        Ok((new, plan))
    }
}

static EMPTY_MANIFEST: Manifest = Manifest { files: BTreeMap::new() };

/// The in-memory **delta warehouse** of one member: the last full scan plus the
/// fingerprint manifest that produced it. [`SymbolDelta::rescan`] applies a
/// file-level delta *in place* — this is what re-introspects only what changed.
#[derive(Debug)]
pub struct SymbolDelta {
    pub repo: String,
    pub manifest: Manifest,
    pub scan: SymbolScan,
}

impl SymbolDelta {
    /// The cold path: one full [`symbols::scan_repo`] + a fresh manifest. The
    /// baseline a later [`rescan`](Self::rescan) diffs against.
    pub fn full_build(
        repo_root: &Path,
        repo_name: &str,
        snapshot_id: Uuid,
        ts: DateTime<Utc>,
    ) -> Result<Self> {
        let files = symbols::discover_files(repo_root);
        let manifest = Manifest::build(repo_root, &files)?;
        let scan = symbols::scan_repo(repo_root, repo_name, snapshot_id, ts)?;
        Ok(Self { repo: repo_name.to_string(), manifest, scan })
    }

    /// The **hot path**: diff the working tree, evict changed/removed rows,
    /// re-parse only added/changed files, relabel renames, merge — mutating
    /// `self` into the new snapshot. Returns the [`DeltaPlan`] that was applied
    /// (empty ⇒ nothing changed, `self` untouched). `snapshot_id`/`ts` tag the
    /// freshly parsed rows.
    pub fn rescan(
        &mut self,
        repo_root: &Path,
        snapshot_id: Uuid,
        ts: DateTime<Utc>,
    ) -> Result<DeltaPlan> {
        let files = symbols::discover_files(repo_root);
        let (new_manifest, plan) = self.manifest.refresh(repo_root, &files)?;

        if plan.is_empty() {
            functional_mark("scan", true, &format!("no-op repo={} files={}", self.repo, files.len()));
            self.manifest = new_manifest;
            return Ok(plan);
        }

        // 1. Evict every row of a changed-or-removed file (removed symbols must
        //    disappear). In-place `retain`, no alloc.
        let mut evicted = 0usize;
        for rel in plan.to_evict() {
            evicted += evict_file(&mut self.scan, rel);
        }
        functional_mark(
            "evict",
            true,
            &format!("repo={} files={} rows={evicted}", self.repo, plan.changed.len() + plan.removed.len()),
        );

        // 2. Re-parse ONLY added+changed files (one syn parse each), then merge.
        let reparse: Vec<DiscoveredFile> = {
            let want: std::collections::BTreeSet<&str> =
                plan.to_reparse().map(|s| s.as_str()).collect();
            files
                .iter()
                .filter(|f| want.contains(symbols::rel_path(repo_root, &f.path).as_str()))
                .cloned()
                .collect()
        };
        let delta_scan =
            symbols::scan_selected_files(repo_root, &self.repo, snapshot_id, ts, &reparse);
        functional_mark(
            "scan",
            true,
            &format!(
                "repo={} reparsed={} syms+={} calls+={}",
                self.repo,
                reparse.len(),
                delta_scan.symbols.len(),
                delta_scan.calls.len()
            ),
        );
        merge_scan(&mut self.scan, delta_scan);

        // 3. Renames: relabel existing rows old→new (parse reused, zero re-derive).
        let mut relabelled = 0usize;
        for (old_rel, new_rel) in &plan.renamed {
            relabelled += relabel_file(&mut self.scan, old_rel, new_rel);
        }
        functional_mark(
            "merge",
            true,
            &format!("repo={} renamed={} relabelled_rows={relabelled}", self.repo, plan.renamed.len()),
        );

        self.manifest = new_manifest;
        Ok(plan)
    }

    /// Evict a file's rows directly (exposed for the resolved/SCIP layer, which
    /// keys on the same repo-relative `file`; see the module SCIP note).
    pub fn evict_file(&mut self, rel: &str) -> usize {
        evict_file(&mut self.scan, rel)
    }
}

/// Drop every row (symbol / call / feature / test) whose `file == rel`, in place.
/// Returns how many rows were evicted. `Vec::retain` ⇒ no allocation.
fn evict_file(scan: &mut SymbolScan, rel: &str) -> usize {
    let before = scan.symbols.len() + scan.calls.len() + scan.features.len() + scan.tests.len();
    scan.symbols.retain(|r| r.file != rel);
    scan.calls.retain(|r| r.file != rel);
    scan.features.retain(|r| r.file != rel);
    scan.tests.retain(|r| r.file != rel);
    before - (scan.symbols.len() + scan.calls.len() + scan.features.len() + scan.tests.len())
}

/// Merge a freshly parsed delta scan into the warehouse scan — a move (`append`),
/// no per-row clone.
fn merge_scan(scan: &mut SymbolScan, mut delta: SymbolScan) {
    scan.symbols.append(&mut delta.symbols);
    scan.calls.append(&mut delta.calls);
    scan.features.append(&mut delta.features);
    scan.tests.append(&mut delta.tests);
}

/// Relabel every row of `old_rel` to `new_rel` (a rename served without a
/// re-parse). Returns rows relabelled.
fn relabel_file(scan: &mut SymbolScan, old_rel: &str, new_rel: &str) -> usize {
    let mut n = 0usize;
    for r in scan.symbols.iter_mut().filter(|r| r.file == old_rel) {
        r.file = new_rel.to_string();
        n += 1;
    }
    for r in scan.calls.iter_mut().filter(|r| r.file == old_rel) {
        r.file = new_rel.to_string();
        n += 1;
    }
    for r in scan.features.iter_mut().filter(|r| r.file == old_rel) {
        r.file = new_rel.to_string();
        n += 1;
    }
    for r in scan.tests.iter_mut().filter(|r| r.file == old_rel) {
        r.file = new_rel.to_string();
        n += 1;
    }
    n
}

/// `functional_status` emit at each delta branch (scan / evict / merge) — records
/// under the `testmatrix` feature, an `#[inline]` no-op otherwise.
#[inline]
fn functional_mark(branch: &str, ok: bool, detail: &str) {
    nornir_testmatrix::functional_status("introspect-delta", branch, ok, detail);
}

#[cfg(test)]
mod tests {
    use super::*;

    fn fp(mtime: i128, size: u64, seed: u8) -> FileFingerprint {
        FileFingerprint { mtime_ns: mtime, size, hash: [seed; 32] }
    }
    fn manifest(entries: &[(&str, FileFingerprint)]) -> Manifest {
        let mut m = Manifest::default();
        for (p, f) in entries {
            m.files.insert((*p).to_string(), f.clone());
        }
        m
    }

    // ── The pure differ: added / changed / removed / renamed ─────────────────

    #[test]
    fn diff_detects_added_changed_removed() {
        let old = manifest(&[("a.rs", fp(1, 10, 1)), ("b.rs", fp(1, 20, 2))]);
        // a.rs same hash → unchanged; b.rs new hash → changed; c.rs new → added;
        // (b.rs stays); nothing removed here except by absence below.
        let new = manifest(&[("a.rs", fp(9, 10, 1)), ("b.rs", fp(1, 20, 9)), ("c.rs", fp(1, 5, 3))]);
        let plan = diff(&old, &new);
        assert_eq!(plan.added, vec!["c.rs".to_string()]);
        assert_eq!(plan.changed, vec!["b.rs".to_string()]);
        assert!(plan.removed.is_empty());
        assert!(plan.renamed.is_empty());
    }

    #[test]
    fn diff_detects_removed() {
        let old = manifest(&[("a.rs", fp(1, 10, 1)), ("gone.rs", fp(1, 20, 2))]);
        let new = manifest(&[("a.rs", fp(1, 10, 1))]);
        let plan = diff(&old, &new);
        assert_eq!(plan.removed, vec!["gone.rs".to_string()]);
        assert!(plan.added.is_empty() && plan.changed.is_empty());
    }

    #[test]
    fn diff_detects_rename_by_identical_content() {
        // old.rs removed + new.rs added with the SAME content hash ⇒ a rename,
        // NOT an add+remove pair.
        let old = manifest(&[("old.rs", fp(1, 10, 7))]);
        let new = manifest(&[("new.rs", fp(2, 10, 7))]);
        let plan = diff(&old, &new);
        assert_eq!(plan.renamed, vec![("old.rs".to_string(), "new.rs".to_string())]);
        assert!(plan.added.is_empty(), "rename is not an add");
        assert!(plan.removed.is_empty(), "rename is not a remove");
    }

    #[test]
    fn diff_empty_when_nothing_changed() {
        let m = manifest(&[("a.rs", fp(1, 10, 1)), ("b.rs", fp(1, 20, 2))]);
        assert!(diff(&m, &m.clone()).is_empty());
    }

    // ── The end-to-end delta over a real on-disk crate ──────────────────────

    /// Write a minimal single-crate repo with `n` `foo{i}.rs` files, each
    /// defining one `fn`, plus a `lib.rs` that `mod`s them. Returns the temp dir.
    fn scaffold(n: usize) -> tempfile::TempDir {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(
            dir.path().join("Cargo.toml"),
            "[package]\nname = \"deltacrate\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
        )
        .unwrap();
        let src = dir.path().join("src");
        std::fs::create_dir_all(&src).unwrap();
        let mut lib = String::new();
        for i in 0..n {
            std::fs::write(src.join(format!("foo{i}.rs")), format!("pub fn item{i}() {{}}\n")).unwrap();
            lib.push_str(&format!("pub mod foo{i};\n"));
        }
        std::fs::write(src.join("lib.rs"), lib).unwrap();
        dir
    }

    fn sym_names(scan: &SymbolScan) -> std::collections::BTreeSet<String> {
        scan.symbols.iter().map(|s| s.item_name.clone()).collect()
    }

    #[test]
    fn full_build_then_noop_rescan_is_empty() {
        let dir = scaffold(3);
        let mut wh =
            SymbolDelta::full_build(dir.path(), "deltacrate", Uuid::new_v4(), Utc::now()).unwrap();
        let baseline = sym_names(&wh.scan);
        // No edits ⇒ the rescan reads NOTHING and changes NOTHING.
        let plan = wh.rescan(dir.path(), Uuid::new_v4(), Utc::now()).unwrap();
        assert!(plan.is_empty(), "unchanged tree ⇒ empty delta: {plan:?}");
        assert_eq!(sym_names(&wh.scan), baseline, "no-op rescan must not alter the row set");
    }

    #[test]
    fn edit_one_file_reparses_only_it_and_evicts_old_symbols() {
        let dir = scaffold(5);
        let mut wh =
            SymbolDelta::full_build(dir.path(), "deltacrate", Uuid::new_v4(), Utc::now()).unwrap();
        assert!(sym_names(&wh.scan).contains("item2"));

        // Rewrite foo2.rs: item2 GONE, replaced by renamed_item + a struct.
        std::fs::write(
            dir.path().join("src/foo2.rs"),
            "pub fn renamed_item() {}\npub struct NewType;\n",
        )
        .unwrap();
        // Make mtime move (tempfs mtime resolution can equal the build's).
        filetime_bump(&dir.path().join("src/foo2.rs"));

        let plan = wh.rescan(dir.path(), Uuid::new_v4(), Utc::now()).unwrap();
        assert_eq!(plan.touched(), 1, "exactly one file changed: {plan:?}");
        assert_eq!(plan.changed, vec!["src/foo2.rs".to_string()]);

        let names = sym_names(&wh.scan);
        // EVICT invariant: the removed symbol is gone.
        assert!(!names.contains("item2"), "evict-on-change: item2 must be gone: {names:?}");
        // MERGE: the new symbols are present.
        assert!(names.contains("renamed_item"));
        assert!(names.contains("NewType"));
        // The OTHER files' symbols are untouched (not re-parsed, still present).
        assert!(names.contains("item0") && names.contains("item4"));
    }

    #[test]
    fn added_and_removed_files_update_the_symbol_set() {
        let dir = scaffold(2);
        let mut wh =
            SymbolDelta::full_build(dir.path(), "deltacrate", Uuid::new_v4(), Utc::now()).unwrap();

        // Add foo9.rs (must be reachable, but the syn symbol scan is per-file and
        // does not require the mod wiring to emit the row).
        std::fs::write(dir.path().join("src/foo9.rs"), "pub fn added9() {}\n").unwrap();
        // Remove foo0.rs.
        std::fs::remove_file(dir.path().join("src/foo0.rs")).unwrap();

        let plan = wh.rescan(dir.path(), Uuid::new_v4(), Utc::now()).unwrap();
        assert_eq!(plan.added, vec!["src/foo9.rs".to_string()]);
        assert_eq!(plan.removed, vec!["src/foo0.rs".to_string()]);

        let names = sym_names(&wh.scan);
        assert!(names.contains("added9"), "added file's symbol present");
        assert!(!names.contains("item0"), "removed file's symbol evicted");
        assert!(names.contains("item1"), "untouched file's symbol kept");
    }

    #[test]
    fn rename_relabels_rows_without_reparsing() {
        let dir = scaffold(3);
        let mut wh =
            SymbolDelta::full_build(dir.path(), "deltacrate", Uuid::new_v4(), Utc::now()).unwrap();
        // Move foo1.rs → bar1.rs verbatim (byte-identical content ⇒ a rename).
        let from = dir.path().join("src/foo1.rs");
        let to = dir.path().join("src/bar1.rs");
        std::fs::rename(&from, &to).unwrap();

        let plan = wh.rescan(dir.path(), Uuid::new_v4(), Utc::now()).unwrap();
        assert_eq!(
            plan.renamed,
            vec![("src/foo1.rs".to_string(), "src/bar1.rs".to_string())],
            "content-identical move is a rename: {plan:?}"
        );
        // The symbol survives (parse reused) and now lives under the new path.
        assert!(wh.scan.symbols.iter().any(|s| s.item_name == "item1" && s.file == "src/bar1.rs"));
        assert!(!wh.scan.symbols.iter().any(|s| s.file == "src/foo1.rs"), "old path gone");
    }

    /// Full-build correctness parity: after a delta rescan the warehouse's symbol
    /// set must equal what a from-scratch full build of the mutated tree produces.
    #[test]
    fn delta_matches_full_rebuild() {
        let dir = scaffold(4);
        let mut wh =
            SymbolDelta::full_build(dir.path(), "deltacrate", Uuid::new_v4(), Utc::now()).unwrap();
        std::fs::write(dir.path().join("src/foo1.rs"), "pub fn changed1() {}\n").unwrap();
        filetime_bump(&dir.path().join("src/foo1.rs"));
        std::fs::write(dir.path().join("src/foo7.rs"), "pub fn brand_new() {}\n").unwrap();
        std::fs::remove_file(dir.path().join("src/foo3.rs")).unwrap();

        wh.rescan(dir.path(), Uuid::new_v4(), Utc::now()).unwrap();
        let from_scratch =
            SymbolDelta::full_build(dir.path(), "deltacrate", Uuid::new_v4(), Utc::now()).unwrap();
        assert_eq!(
            sym_names(&wh.scan),
            sym_names(&from_scratch.scan),
            "delta warehouse must equal a full rebuild of the mutated tree"
        );
    }

    /// Nudge a file's mtime forward so the cheap `(mtime,size)` gate can't miss an
    /// edit that lands within the filesystem's mtime resolution of the build.
    fn filetime_bump(path: &Path) {
        // A second write with a distinct length already moved size; but for an
        // equal-length edit we must move mtime. Re-open + set mtime via a tiny
        // sleep-free trick: write is enough on most FS, but force it explicitly.
        let content = std::fs::read(path).unwrap();
        std::thread::sleep(std::time::Duration::from_millis(10));
        std::fs::write(path, &content).unwrap();
    }
}