Skip to main content

aft/
legacy_partitions.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::io;
4use std::path::{Component, Path, PathBuf};
5use std::time::{Duration, SystemTime};
6
7use rusqlite::{Connection, OpenFlags};
8use serde_json::json;
9
10const ROOT_KEYED_COPY_DISK_FLOOR_NUMERATOR: u64 = 3;
11const ROOT_KEYED_COPY_DISK_FLOOR_DENOMINATOR: u64 = 2;
12const SQLITE_SUFFIXES: [&str; 4] = [".sqlite-wal", ".sqlite-shm", ".sqlite-journal", ".sqlite"];
13
14#[derive(Clone, Copy, Debug, Eq, PartialEq)]
15pub enum LegacyPartitionKind {
16    Callgraph,
17    Inspect,
18}
19
20impl LegacyPartitionKind {
21    pub fn as_str(self) -> &'static str {
22        match self {
23            Self::Callgraph => "callgraph",
24            Self::Inspect => "inspect",
25        }
26    }
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct LegacyPartitionInventoryEntry {
31    pub harness: String,
32    pub kind: LegacyPartitionKind,
33    pub key: String,
34    /// Logical partition path. The current legacy layout stores flat files, but
35    /// callers treat `<storage>/<harness>/<domain>/<key>` as the partition ID.
36    pub path: PathBuf,
37    pub bytes: u64,
38    pub callgraph_pointer_mtime: Option<SystemTime>,
39    pub inspect_tier2_last_full_run: Option<i64>,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
43pub struct LegacyHarnessDuplication {
44    pub harness: String,
45    pub partitions: usize,
46    pub bytes: u64,
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub struct DiskFloorDecision {
51    pub source_bytes: u64,
52    pub available_bytes: u64,
53    pub required_bytes: u64,
54}
55
56impl DiskFloorDecision {
57    pub fn allows_copy(self) -> bool {
58        self.available_bytes >= self.required_bytes
59    }
60
61    pub fn should_skip_copy(self) -> bool {
62        !self.allows_copy()
63    }
64
65    pub fn warning_message(self, source: &Path, target: &Path) -> String {
66        format!(
67            "Skipping root-keyed cache copy from {} into {}: free disk ({}) is below the required 1.5× floor ({} for {} source bytes).",
68            source.display(),
69            target.display(),
70            self.available_bytes,
71            self.required_bytes,
72            self.source_bytes
73        )
74    }
75
76    pub fn configure_warning(self, source: &Path, target: &Path) -> serde_json::Value {
77        json!({
78            "kind": "root_keyed_disk_floor",
79            "source_path": source.display().to_string(),
80            "target_path": target.display().to_string(),
81            "bytes_source": self.source_bytes,
82            "bytes_free": self.available_bytes,
83            "bytes_required": self.required_bytes,
84            "message": self.warning_message(source, target),
85        })
86    }
87}
88
89pub fn required_root_keyed_copy_free_bytes(source_bytes: u64) -> u64 {
90    source_bytes
91        .saturating_mul(ROOT_KEYED_COPY_DISK_FLOOR_NUMERATOR)
92        .saturating_add(ROOT_KEYED_COPY_DISK_FLOOR_DENOMINATOR - 1)
93        / ROOT_KEYED_COPY_DISK_FLOOR_DENOMINATOR
94}
95
96pub fn evaluate_root_keyed_copy_disk_floor(
97    source_bytes: u64,
98    available_bytes: u64,
99) -> DiskFloorDecision {
100    DiskFloorDecision {
101        source_bytes,
102        available_bytes,
103        required_bytes: required_root_keyed_copy_free_bytes(source_bytes),
104    }
105}
106
107/// Read free bytes for `path` from the filesystem containing the nearest
108/// existing ancestor. Future root-keyed migration/copy paths use this seam for
109/// the 1.5× disk-floor preflight.
110pub fn available_disk_for(path: &Path) -> io::Result<u64> {
111    #[cfg(unix)]
112    {
113        use std::ffi::CString;
114        use std::os::unix::ffi::OsStrExt;
115
116        let probe = existing_ancestor(path);
117        let c_path = CString::new(probe.as_os_str().as_bytes())
118            .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL byte"))?;
119        let mut stat = std::mem::MaybeUninit::<libc::statvfs>::uninit();
120        let result = unsafe { libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) };
121        if result != 0 {
122            return Err(io::Error::last_os_error());
123        }
124        let stat = unsafe { stat.assume_init() };
125        Ok((stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64))
126    }
127
128    #[cfg(windows)]
129    {
130        let _ = path;
131        // Root-keyed migration is not wired on Windows yet. Mirror the existing
132        // storage-migration posture so call sites can remain total until the
133        // Windows-specific disk probe lands.
134        Ok(u64::MAX)
135    }
136}
137
138/// Cheap structural predicate for the coexistence window: true when `candidate`
139/// points anywhere under `<storage>/<harness>/(callgraph|inspect)`.
140pub fn is_legacy_harness_partition_path(storage_root: &Path, candidate: &Path) -> bool {
141    let storage_root = lexical_normalize(storage_root);
142    let candidate = if candidate.is_absolute() {
143        lexical_normalize(candidate)
144    } else {
145        lexical_normalize(&storage_root.join(candidate))
146    };
147
148    let Ok(relative) = candidate.strip_prefix(&storage_root) else {
149        return false;
150    };
151
152    let mut components = relative.components();
153    let Some(Component::Normal(_harness)) = components.next() else {
154        return false;
155    };
156    let Some(Component::Normal(domain)) = components.next() else {
157        return false;
158    };
159
160    matches!(domain.to_str(), Some("callgraph" | "inspect"))
161}
162
163#[track_caller]
164pub fn debug_assert_not_legacy_harness_partition_path(storage_root: &Path, candidate: &Path) {
165    debug_assert!(
166        !is_legacy_harness_partition_path(storage_root, candidate),
167        "new-layout write path must not point into a legacy harness partition: {}",
168        candidate.display()
169    );
170}
171
172pub fn refuse_legacy_partition_write(
173    storage_root: &Path,
174    candidate: &Path,
175    operation: &str,
176) -> io::Result<()> {
177    if is_legacy_harness_partition_path(storage_root, candidate) {
178        return Err(io::Error::new(
179            io::ErrorKind::PermissionDenied,
180            format!(
181                "refusing {operation} into legacy harness partition {}",
182                candidate.display()
183            ),
184        ));
185    }
186    Ok(())
187}
188
189#[track_caller]
190pub fn guard_new_layout_write_path(
191    storage_root: &Path,
192    candidate: &Path,
193    operation: &str,
194) -> io::Result<()> {
195    debug_assert_not_legacy_harness_partition_path(storage_root, candidate);
196    refuse_legacy_partition_write(storage_root, candidate, operation)
197}
198
199pub fn inventory_legacy_partitions(
200    storage_root: &Path,
201) -> io::Result<Vec<LegacyPartitionInventoryEntry>> {
202    let storage_root = lexical_normalize(storage_root);
203    let boundary = match crate::walk_boundary::DeviceBoundary::for_root(&storage_root) {
204        Ok(boundary) => boundary,
205        // Keep the existing empty-inventory behavior for a storage root that has
206        // not been created yet; no recursive walk has started in that case.
207        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
208        Err(error) => return Err(error),
209    };
210    let mut entries = Vec::new();
211    for harness_entry in sorted_read_dir(&storage_root)? {
212        if !harness_entry.file_type()?.is_dir() {
213            continue;
214        }
215        let harness = harness_entry.file_name().to_string_lossy().to_string();
216        let harness_path = harness_entry.path();
217        // A mounted child can disappear while recursive inventory holds its
218        // ReadDir, causing closedir ENXIO to abort in Drop. Do not enter it.
219        if !boundary.should_descend(&harness_path)? {
220            crate::slog_warn!(
221                "legacy partition inventory skipped foreign filesystem mount {}",
222                harness_path.display()
223            );
224            continue;
225        }
226        let callgraph_path = harness_path.join(LegacyPartitionKind::Callgraph.as_str());
227        if callgraph_path.is_dir() && boundary.should_descend(&callgraph_path)? {
228            entries.extend(scan_legacy_callgraph_partitions(
229                &harness,
230                &callgraph_path,
231                &boundary,
232            )?);
233        }
234        let inspect_path = harness_path.join(LegacyPartitionKind::Inspect.as_str());
235        if inspect_path.is_dir() && boundary.should_descend(&inspect_path)? {
236            entries.extend(scan_legacy_inspect_partitions(
237                &harness,
238                &inspect_path,
239                &boundary,
240            )?);
241        }
242    }
243    entries.sort_by(|left, right| {
244        left.harness
245            .cmp(&right.harness)
246            .then_with(|| left.kind.as_str().cmp(right.kind.as_str()))
247            .then_with(|| left.key.cmp(&right.key))
248    });
249    Ok(entries)
250}
251
252pub fn summarize_legacy_partition_duplication(
253    storage_root: &Path,
254) -> io::Result<Vec<LegacyHarnessDuplication>> {
255    let mut summaries = BTreeMap::<String, LegacyHarnessDuplication>::new();
256    for entry in inventory_legacy_partitions(storage_root)? {
257        let summary =
258            summaries
259                .entry(entry.harness.clone())
260                .or_insert_with(|| LegacyHarnessDuplication {
261                    harness: entry.harness.clone(),
262                    partitions: 0,
263                    bytes: 0,
264                });
265        summary.partitions += 1;
266        summary.bytes = summary.bytes.saturating_add(entry.bytes);
267    }
268    Ok(summaries.into_values().collect())
269}
270
271#[derive(Clone, Debug, Default)]
272struct PartitionAccumulator {
273    bytes: u64,
274    callgraph_pointer_mtime: Option<SystemTime>,
275    inspect_tier2_last_full_run: Option<i64>,
276}
277
278fn scan_legacy_callgraph_partitions(
279    harness: &str,
280    callgraph_dir: &Path,
281    boundary: &crate::walk_boundary::DeviceBoundary,
282) -> io::Result<Vec<LegacyPartitionInventoryEntry>> {
283    let mut partitions = BTreeMap::<String, PartitionAccumulator>::new();
284    for entry in sorted_read_dir(callgraph_dir)? {
285        let file_type = entry.file_type()?;
286        let name = entry.file_name().to_string_lossy().to_string();
287        if file_type.is_dir() {
288            if !looks_like_partition_key(&name) {
289                continue;
290            }
291            let partition = partitions.entry(name.clone()).or_default();
292            partition.bytes = partition
293                .bytes
294                .saturating_add(tree_size(&entry.path(), boundary)?);
295            if partition.callgraph_pointer_mtime.is_none() {
296                partition.callgraph_pointer_mtime = callgraph_pointer_mtime(callgraph_dir, &name);
297            }
298            continue;
299        }
300
301        let Some(key) = callgraph_partition_key_from_name(&name) else {
302            continue;
303        };
304        let partition = partitions.entry(key.clone()).or_default();
305        partition.bytes = partition.bytes.saturating_add(file_size(&entry.path())?);
306        if name.ends_with(".current") {
307            partition.callgraph_pointer_mtime =
308                entry.metadata().and_then(|meta| meta.modified()).ok();
309        }
310    }
311
312    Ok(partitions
313        .into_iter()
314        .map(|(key, partition)| LegacyPartitionInventoryEntry {
315            harness: harness.to_string(),
316            kind: LegacyPartitionKind::Callgraph,
317            path: callgraph_dir.join(&key),
318            key,
319            bytes: partition.bytes,
320            callgraph_pointer_mtime: partition.callgraph_pointer_mtime,
321            inspect_tier2_last_full_run: None,
322        })
323        .collect())
324}
325
326fn scan_legacy_inspect_partitions(
327    harness: &str,
328    inspect_dir: &Path,
329    boundary: &crate::walk_boundary::DeviceBoundary,
330) -> io::Result<Vec<LegacyPartitionInventoryEntry>> {
331    let mut partitions = BTreeMap::<String, PartitionAccumulator>::new();
332    for entry in sorted_read_dir(inspect_dir)? {
333        let file_type = entry.file_type()?;
334        let name = entry.file_name().to_string_lossy().to_string();
335        if file_type.is_dir() {
336            if !looks_like_partition_key(&name) {
337                continue;
338            }
339            let partition = partitions.entry(name.clone()).or_default();
340            partition.bytes = partition
341                .bytes
342                .saturating_add(tree_size(&entry.path(), boundary)?);
343            if partition.inspect_tier2_last_full_run.is_none() {
344                partition.inspect_tier2_last_full_run =
345                    inspect_tier2_last_full_run(inspect_dir, &name);
346            }
347            continue;
348        }
349
350        let Some(key) = inspect_partition_key_from_name(&name) else {
351            continue;
352        };
353        let partition = partitions.entry(key.clone()).or_default();
354        partition.bytes = partition.bytes.saturating_add(file_size(&entry.path())?);
355    }
356
357    Ok(partitions
358        .into_iter()
359        .map(|(key, mut partition)| {
360            if partition.inspect_tier2_last_full_run.is_none() {
361                partition.inspect_tier2_last_full_run =
362                    inspect_tier2_last_full_run(inspect_dir, &key);
363            }
364            LegacyPartitionInventoryEntry {
365                harness: harness.to_string(),
366                kind: LegacyPartitionKind::Inspect,
367                path: inspect_dir.join(&key),
368                key,
369                bytes: partition.bytes,
370                callgraph_pointer_mtime: None,
371                inspect_tier2_last_full_run: partition.inspect_tier2_last_full_run,
372            }
373        })
374        .collect())
375}
376
377fn callgraph_pointer_mtime(callgraph_dir: &Path, key: &str) -> Option<SystemTime> {
378    for candidate in [
379        callgraph_dir.join(format!("{key}.current")),
380        callgraph_dir.join(key).join(format!("{key}.current")),
381    ] {
382        if let Ok(modified) = fs::metadata(candidate).and_then(|metadata| metadata.modified()) {
383            return Some(modified);
384        }
385    }
386    None
387}
388
389fn inspect_tier2_last_full_run(inspect_dir: &Path, key: &str) -> Option<i64> {
390    let sqlite_path = [
391        inspect_dir.join(format!("{key}.sqlite")),
392        inspect_dir.join(key).join(format!("{key}.sqlite")),
393    ]
394    .into_iter()
395    .find(|candidate| candidate.is_file())?;
396
397    let conn = Connection::open_with_flags(&sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY).ok()?;
398    conn.busy_timeout(Duration::from_millis(500)).ok()?;
399    conn.query_row("SELECT MAX(last_full_run) FROM tier2_meta", [], |row| {
400        row.get::<_, Option<i64>>(0)
401    })
402    .ok()
403    .flatten()
404}
405
406fn callgraph_partition_key_from_name(name: &str) -> Option<String> {
407    if name.contains(".tmp.") {
408        return None;
409    }
410    if let Some(key) = name.strip_suffix(".current") {
411        return looks_like_partition_key(key).then(|| key.to_string());
412    }
413    let base = sqliteish_base_name(name)?;
414    let key = if let Some((candidate, generation)) = base.split_once(".g") {
415        if generation.is_empty() {
416            return None;
417        }
418        candidate
419    } else {
420        base
421    };
422    looks_like_partition_key(key).then(|| key.to_string())
423}
424
425fn inspect_partition_key_from_name(name: &str) -> Option<String> {
426    if name.contains(".tmp.") {
427        return None;
428    }
429    let base = sqliteish_base_name(name)?;
430    looks_like_partition_key(base).then(|| base.to_string())
431}
432
433fn sqliteish_base_name(name: &str) -> Option<&str> {
434    SQLITE_SUFFIXES
435        .iter()
436        .find_map(|suffix| name.strip_suffix(suffix))
437}
438
439fn looks_like_partition_key(value: &str) -> bool {
440    value.len() == 16 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
441}
442
443fn file_size(path: &Path) -> io::Result<u64> {
444    Ok(fs::metadata(path)?.len())
445}
446
447fn tree_size(path: &Path, boundary: &crate::walk_boundary::DeviceBoundary) -> io::Result<u64> {
448    if !path.exists() {
449        return Ok(0);
450    }
451    let metadata = fs::metadata(path)?;
452    if metadata.is_file() {
453        return Ok(metadata.len());
454    }
455    if !metadata.is_dir() {
456        return Ok(0);
457    }
458    if !boundary.should_descend(path)? {
459        crate::slog_warn!(
460            "legacy partition inventory skipped foreign filesystem mount {}",
461            path.display()
462        );
463        return Ok(0);
464    }
465
466    let mut total = 0_u64;
467    for entry in fs::read_dir(path)? {
468        total = total.saturating_add(tree_size(&entry?.path(), boundary)?);
469    }
470    Ok(total)
471}
472
473fn sorted_read_dir(path: &Path) -> io::Result<Vec<fs::DirEntry>> {
474    let entries = match fs::read_dir(path) {
475        Ok(entries) => entries,
476        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
477        Err(error) => return Err(error),
478    };
479    let mut entries = entries.collect::<io::Result<Vec<_>>>()?;
480    entries.sort_by_key(|entry| entry.file_name());
481    Ok(entries)
482}
483
484fn lexical_normalize(path: &Path) -> PathBuf {
485    let mut normalized = PathBuf::new();
486    for component in path.components() {
487        match component {
488            Component::ParentDir => {
489                if !normalized.pop() {
490                    normalized.push(component);
491                }
492            }
493            Component::CurDir => {}
494            other => normalized.push(other.as_os_str()),
495        }
496    }
497    normalized
498}
499
500#[cfg(unix)]
501fn existing_ancestor(path: &Path) -> &Path {
502    let mut current = path;
503    while !current.exists() {
504        if let Some(parent) = current.parent() {
505            current = parent;
506        } else {
507            break;
508        }
509    }
510    current
511}
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516    use filetime::FileTime;
517    use rusqlite::params;
518    use std::panic::catch_unwind;
519    use std::time::UNIX_EPOCH;
520    use tempfile::TempDir;
521
522    #[test]
523    fn legacy_partition_guard_matches_exact_domains() {
524        let storage_root = PathBuf::from("/tmp/aft-storage");
525        assert!(is_legacy_harness_partition_path(
526            &storage_root,
527            &storage_root.join("opencode/callgraph/0123456789abcdef.current")
528        ));
529        assert!(is_legacy_harness_partition_path(
530            &storage_root,
531            &storage_root.join("pi/inspect/0123456789abcdef.sqlite")
532        ));
533        assert!(!is_legacy_harness_partition_path(
534            &storage_root,
535            &storage_root.join("opencode/callgraph-old/0123456789abcdef.sqlite")
536        ));
537        assert!(!is_legacy_harness_partition_path(
538            &storage_root,
539            &storage_root.join("index/0123456789abcdef.sqlite")
540        ));
541        assert!(!is_legacy_harness_partition_path(
542            &storage_root,
543            Path::new("/elsewhere/opencode/callgraph/0123456789abcdef.sqlite")
544        ));
545    }
546
547    #[test]
548    fn debug_assert_and_refusal_cover_legacy_write_paths() {
549        let storage_root = PathBuf::from("/tmp/aft-storage");
550        let legacy_target = storage_root.join("opencode/callgraph/0123456789abcdef.sqlite");
551        let panic = catch_unwind(|| {
552            debug_assert_not_legacy_harness_partition_path(&storage_root, &legacy_target);
553        });
554        assert!(panic.is_err());
555
556        let error = refuse_legacy_partition_write(&storage_root, &legacy_target, "publish")
557            .expect_err("legacy write must be refused");
558        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
559        assert!(error
560            .to_string()
561            .contains("refusing publish into legacy harness partition"));
562    }
563
564    #[test]
565    fn root_keyed_copy_disk_floor_boundaries() {
566        let exact = evaluate_root_keyed_copy_disk_floor(20, 30);
567        assert_eq!(exact.required_bytes, 30);
568        assert!(exact.allows_copy());
569        assert!(!exact.should_skip_copy());
570
571        let below = evaluate_root_keyed_copy_disk_floor(20, 29);
572        assert!(below.should_skip_copy());
573        assert!(below
574            .warning_message(Path::new("/legacy"), Path::new("/shared"))
575            .contains("1.5× floor"));
576
577        let above = evaluate_root_keyed_copy_disk_floor(20, 31);
578        assert!(above.allows_copy());
579        assert_eq!(
580            below.configure_warning(Path::new("/legacy"), Path::new("/shared"))["kind"],
581            "root_keyed_disk_floor"
582        );
583    }
584
585    #[test]
586    fn inventory_fixture_reports_partition_sizes_and_freshness() {
587        let fixture = write_legacy_inventory_fixture();
588        let storage_root = fixture.temp.path();
589
590        let inventory = inventory_legacy_partitions(storage_root).expect("inventory");
591        assert_eq!(inventory.len(), 2);
592
593        let callgraph = inventory
594            .iter()
595            .find(|entry| entry.kind == LegacyPartitionKind::Callgraph)
596            .expect("callgraph entry");
597        let expected_callgraph_bytes = partition_bytes_on_disk(
598            &storage_root.join("opencode/callgraph"),
599            "0123456789abcdef",
600            callgraph_partition_key_from_name,
601        );
602        assert_eq!(callgraph.harness, "opencode");
603        assert_eq!(callgraph.key, "0123456789abcdef");
604        assert_eq!(
605            callgraph.path,
606            storage_root.join("opencode/callgraph/0123456789abcdef")
607        );
608        assert_eq!(callgraph.bytes, expected_callgraph_bytes);
609        let pointer_secs = callgraph
610            .callgraph_pointer_mtime
611            .expect("pointer mtime")
612            .duration_since(UNIX_EPOCH)
613            .expect("mtime after epoch")
614            .as_secs();
615        assert_eq!(pointer_secs, 1_750_000_000);
616        assert_eq!(callgraph.inspect_tier2_last_full_run, None);
617
618        let inspect = inventory
619            .iter()
620            .find(|entry| entry.kind == LegacyPartitionKind::Inspect)
621            .expect("inspect entry");
622        let minimum_inspect_bytes =
623            file_size(&storage_root.join("pi/inspect/fedcba9876543210.sqlite"))
624                .expect("inspect sqlite size");
625        assert_eq!(inspect.harness, "pi");
626        assert_eq!(inspect.key, "fedcba9876543210");
627        assert_eq!(
628            inspect.path,
629            storage_root.join("pi/inspect/fedcba9876543210")
630        );
631        assert!(inspect.bytes >= minimum_inspect_bytes);
632        assert_eq!(inspect.callgraph_pointer_mtime, None);
633        assert_eq!(inspect.inspect_tier2_last_full_run, Some(250));
634
635        let summary = summarize_legacy_partition_duplication(storage_root).expect("summary");
636        assert_eq!(
637            summary,
638            vec![
639                LegacyHarnessDuplication {
640                    harness: "opencode".to_string(),
641                    partitions: 1,
642                    bytes: expected_callgraph_bytes,
643                },
644                LegacyHarnessDuplication {
645                    harness: "pi".to_string(),
646                    partitions: 1,
647                    bytes: inspect.bytes,
648                },
649            ]
650        );
651    }
652
653    struct LegacyInventoryFixture {
654        temp: TempDir,
655    }
656
657    fn write_legacy_inventory_fixture() -> LegacyInventoryFixture {
658        let temp = tempfile::tempdir().expect("tempdir");
659
660        let callgraph_dir = temp.path().join("opencode/callgraph");
661        fs::create_dir_all(&callgraph_dir).expect("create callgraph dir");
662        fs::write(
663            callgraph_dir.join("0123456789abcdef.current"),
664            b"0123456789abcdef.g1.1.sqlite\n",
665        )
666        .expect("write pointer");
667        fs::write(
668            callgraph_dir.join("0123456789abcdef.g1.1.sqlite"),
669            b"callgraph-db",
670        )
671        .expect("write generation db");
672        fs::write(
673            callgraph_dir.join("0123456789abcdef.g1.1.sqlite-wal"),
674            b"wal",
675        )
676        .expect("write generation wal");
677        fs::write(
678            callgraph_dir.join("0123456789abcdef.current.tmp.123"),
679            b"ignored-temp",
680        )
681        .expect("write ignored temp");
682        filetime::set_file_mtime(
683            callgraph_dir.join("0123456789abcdef.current"),
684            FileTime::from_unix_time(1_750_000_000, 0),
685        )
686        .expect("set pointer mtime");
687
688        let inspect_dir = temp.path().join("pi/inspect");
689        fs::create_dir_all(&inspect_dir).expect("create inspect dir");
690        let sqlite_path = inspect_dir.join("fedcba9876543210.sqlite");
691        let conn = Connection::open(&sqlite_path).expect("open inspect db");
692        conn.execute(
693            "CREATE TABLE tier2_meta (category TEXT NOT NULL, project_key TEXT NOT NULL, last_full_run INTEGER NOT NULL)",
694            [],
695        )
696        .expect("create tier2_meta");
697        conn.execute(
698            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3)",
699            params!["dead_code", "fedcba9876543210", 100_i64],
700        )
701        .expect("insert first tier2 row");
702        conn.execute(
703            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3)",
704            params!["duplicates", "fedcba9876543210", 250_i64],
705        )
706        .expect("insert second tier2 row");
707        drop(conn);
708        fs::write(inspect_dir.join("misc.txt"), b"ignored").expect("write ignored file");
709
710        LegacyInventoryFixture { temp }
711    }
712
713    fn partition_bytes_on_disk(
714        domain_dir: &Path,
715        expected_key: &str,
716        key_fn: fn(&str) -> Option<String>,
717    ) -> u64 {
718        sorted_read_dir(domain_dir)
719            .expect("read partition dir")
720            .into_iter()
721            .filter_map(|entry| {
722                let name = entry.file_name().to_string_lossy().to_string();
723                let key = key_fn(&name)?;
724                if key != expected_key {
725                    return None;
726                }
727                Some(file_size(&entry.path()).expect("partition file size"))
728            })
729            .sum()
730    }
731}