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