use std::path::Path;
use ahash::AHashSet;
use serde::Serialize;
use thiserror::Error;
use crate::store::{BLOBS_DIR, INDEX_FILE, StoreError, VIEWS_DIR, acquire_lock, read_index, wipe_blobs};
const BLOB_SUFFIXES: [&str; 4] = [".fm.msgpack", ".doc.msgpack", ".chunk.msgpack", ".rref.msgpack"];
const LEGACY_BLOB_SUFFIXES: [&str; 2] = [".l1.msgpack", ".l2.msgpack"];
const TELEMETRY_FILENAME: &str = "telemetry.jsonl";
#[derive(Debug, Error)]
pub enum GcError {
#[error(transparent)]
Store(#[from] StoreError),
#[error("io error on {path}: {source}")]
Io {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheComponent {
Blobs,
Views,
Lance,
GitCache,
Telemetry,
All,
}
impl CacheComponent {
pub fn as_str(self) -> &'static str {
match self {
CacheComponent::Blobs => "blobs",
CacheComponent::Views => "views",
CacheComponent::Lance => "lance",
CacheComponent::GitCache => "git-cache",
CacheComponent::Telemetry => "telemetry",
CacheComponent::All => "all",
}
}
}
impl std::str::FromStr for CacheComponent {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"blobs" => Ok(CacheComponent::Blobs),
"views" => Ok(CacheComponent::Views),
"lance" => Ok(CacheComponent::Lance),
"git-cache" => Ok(CacheComponent::GitCache),
"telemetry" => Ok(CacheComponent::Telemetry),
"all" => Ok(CacheComponent::All),
other => Err(format!(
"unknown cache component {other:?}; expected one of \
blobs|views|lance|git-cache|telemetry|all"
)),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct GcReport {
pub scanned: usize,
pub removed: usize,
pub bytes_freed: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct CacheStats {
pub blobs_bytes: u64,
pub views_bytes: u64,
pub lance_bytes: u64,
pub git_cache_bytes: u64,
pub telemetry_bytes: u64,
pub git_history_bytes: u64,
pub total_bytes: u64,
pub other_bytes: u64,
pub blob_count: usize,
pub orphan_blob_count: usize,
pub blob_accounting_ok: bool,
pub per_view_file_count: Vec<(String, usize)>,
pub rss_bytes: Option<u64>,
pub peak_rss_bytes: Option<u64>,
}
pub fn collect_referenced_hashes(basemind_dir: &Path) -> Result<AHashSet<String>, GcError> {
let mut referenced = AHashSet::new();
let views_dir = basemind_dir.join(VIEWS_DIR);
if !views_dir.exists() {
return Ok(referenced);
}
for entry in read_dir(&views_dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: views_dir.clone(),
source,
})?;
let view_dir = entry.path();
if !view_dir.is_dir() {
continue;
}
if !view_dir.join(INDEX_FILE).exists() {
tracing::warn!(view = %view_dir.display(), "view has no index.msgpack; skipping");
continue;
}
let index = match read_index(&view_dir) {
Ok(Some(idx)) => idx,
Ok(None) => continue,
Err(e) => return Err(GcError::Store(e)),
};
for entry in index.files.values() {
referenced.insert(entry.hash_hex.clone());
}
for entry in index.doc_files.values() {
referenced.insert(entry.hash_hex.clone());
}
}
Ok(referenced)
}
pub fn gc_blobs(basemind_dir: &Path, referenced: &AHashSet<String>) -> Result<GcReport, GcError> {
let blobs_dir = basemind_dir.join(BLOBS_DIR);
let mut report = GcReport {
scanned: 0,
removed: 0,
bytes_freed: 0,
};
if !blobs_dir.exists() {
return Ok(report);
}
for entry in read_dir(&blobs_dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: blobs_dir.clone(),
source,
})?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let is_legacy = LEGACY_BLOB_SUFFIXES.iter().any(|suffix| file_name.ends_with(suffix));
let Some(stem) = blob_stem(file_name) else {
report.scanned += 1;
if is_legacy {
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
std::fs::remove_file(&path).map_err(|source| GcError::Io {
path: path.clone(),
source,
})?;
report.removed += 1;
report.bytes_freed += size;
}
continue;
};
report.scanned += 1;
if referenced.contains(stem) {
continue;
}
let size = std::fs::metadata(&path)
.map_err(|source| GcError::Io {
path: path.clone(),
source,
})?
.len();
std::fs::remove_file(&path).map_err(|source| GcError::Io {
path: path.clone(),
source,
})?;
report.removed += 1;
report.bytes_freed += size;
}
Ok(report)
}
pub fn run_gc(basemind_dir: &Path) -> Result<GcReport, GcError> {
if let Some(workdir) = basemind_dir.parent()
&& crate::git::Repo::discover(workdir)
.map(|r| r.has_linked_worktrees())
.unwrap_or(false)
{
tracing::warn!(
"blob GC skipped: clone has linked worktrees (shared blob cache); run per-clone GC is not yet worktree-aware"
);
return Ok(GcReport {
scanned: 0,
removed: 0,
bytes_freed: 0,
});
}
let _lock = acquire_lock(basemind_dir)?;
let referenced = collect_referenced_hashes(basemind_dir)?;
gc_blobs(basemind_dir, &referenced)
}
pub fn clear_component(basemind_dir: &Path, component: CacheComponent) -> Result<(), GcError> {
match component {
CacheComponent::Blobs => wipe_blobs(basemind_dir)?,
CacheComponent::Views => remove_dir_if_exists(&basemind_dir.join(VIEWS_DIR))?,
CacheComponent::Lance => clear_lance(basemind_dir)?,
CacheComponent::GitCache => remove_dir_if_exists(&basemind_dir.join(crate::git_cache::GIT_CACHE_DIR))?,
CacheComponent::Telemetry => remove_file_if_exists(&basemind_dir.join(TELEMETRY_FILENAME))?,
CacheComponent::All => remove_dir_if_exists(basemind_dir)?,
}
Ok(())
}
pub fn clear_single_view(basemind_dir: &Path, name: &str) -> Result<(), GcError> {
if name.is_empty() || name.contains('/') || name.contains('\\') || name == "." || name == ".." {
return Err(GcError::Io {
path: basemind_dir.join(VIEWS_DIR).join(name),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid view name {name:?}: must be a single path component"),
),
});
}
remove_dir_if_exists(&basemind_dir.join(VIEWS_DIR).join(name))
}
pub fn cache_stats(basemind_dir: &Path) -> Result<CacheStats, GcError> {
let blobs_dir = basemind_dir.join(BLOBS_DIR);
let referenced = match collect_referenced_hashes(basemind_dir) {
Ok(set) => Some(set),
Err(e) => {
tracing::warn!(
error = %e,
"cache_stats: could not read a view index (stale schema or corrupt); \
reporting sizes only, orphan accounting skipped"
);
None
}
};
let blob_accounting_ok = referenced.is_some();
let mut blob_count = 0usize;
let mut orphan_blob_count = 0usize;
if blobs_dir.exists() {
for entry in read_dir(&blobs_dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: blobs_dir.clone(),
source,
})?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(stem) = path.file_name().and_then(|n| n.to_str()).and_then(blob_stem) else {
continue;
};
blob_count += 1;
if let Some(referenced) = &referenced
&& !referenced.contains(stem)
{
orphan_blob_count += 1;
}
}
}
let blobs_bytes = dir_size(&blobs_dir)?;
let views_bytes = dir_size(&basemind_dir.join(VIEWS_DIR))?;
let lance_bytes = dir_size(&basemind_dir.join("lance"))?;
let git_cache_bytes = dir_size(&basemind_dir.join(crate::git_cache::GIT_CACHE_DIR))?;
let telemetry_bytes = file_size(&basemind_dir.join(TELEMETRY_FILENAME))?;
let git_history_bytes = dir_size(&basemind_dir.join(crate::git_history::GIT_HISTORY_DIR))?;
let total_bytes = dir_size(basemind_dir)?;
let accounted = blobs_bytes + views_bytes + lance_bytes + git_cache_bytes + telemetry_bytes + git_history_bytes;
let other_bytes = total_bytes.saturating_sub(accounted);
let rss = crate::sysres::sample();
Ok(CacheStats {
blobs_bytes,
views_bytes,
lance_bytes,
git_cache_bytes,
telemetry_bytes,
git_history_bytes,
total_bytes,
other_bytes,
blob_count,
orphan_blob_count,
blob_accounting_ok,
per_view_file_count: per_view_file_count(basemind_dir)?,
rss_bytes: rss.current_bytes,
peak_rss_bytes: rss.peak_bytes,
})
}
fn blob_stem(file_name: &str) -> Option<&str> {
BLOB_SUFFIXES.iter().find_map(|suffix| file_name.strip_suffix(suffix))
}
fn per_view_file_count(basemind_dir: &Path) -> Result<Vec<(String, usize)>, GcError> {
let mut out = Vec::new();
let views_dir = basemind_dir.join(VIEWS_DIR);
if !views_dir.exists() {
return Ok(out);
}
for entry in read_dir(&views_dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: views_dir.clone(),
source,
})?;
let view_dir = entry.path();
if !view_dir.is_dir() {
continue;
}
let name = view_dir.file_name().and_then(|n| n.to_str()).unwrap_or("?").to_string();
let count = read_index(&view_dir).ok().flatten().map_or(0, |idx| idx.files.len());
out.push((name, count));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
#[cfg(feature = "intelligence")]
fn clear_lance(basemind_dir: &Path) -> Result<(), GcError> {
remove_dir_if_exists(&basemind_dir.join(crate::store::LANCE_DIR))
}
#[cfg(not(feature = "intelligence"))]
fn clear_lance(_basemind_dir: &Path) -> Result<(), GcError> {
Ok(())
}
fn remove_dir_if_exists(dir: &Path) -> Result<(), GcError> {
if dir.exists() {
std::fs::remove_dir_all(dir).map_err(|source| GcError::Io {
path: dir.to_path_buf(),
source,
})?;
}
Ok(())
}
fn remove_file_if_exists(path: &Path) -> Result<(), GcError> {
if path.exists() {
std::fs::remove_file(path).map_err(|source| GcError::Io {
path: path.to_path_buf(),
source,
})?;
}
Ok(())
}
fn read_dir(dir: &Path) -> Result<std::fs::ReadDir, GcError> {
std::fs::read_dir(dir).map_err(|source| GcError::Io {
path: dir.to_path_buf(),
source,
})
}
#[cfg(unix)]
fn on_disk_size(meta: &std::fs::Metadata) -> u64 {
use std::os::unix::fs::MetadataExt;
meta.blocks().saturating_mul(512)
}
#[cfg(not(unix))]
fn on_disk_size(meta: &std::fs::Metadata) -> u64 {
meta.len()
}
fn file_size(path: &Path) -> Result<u64, GcError> {
if !path.exists() {
return Ok(0);
}
let meta = std::fs::symlink_metadata(path).map_err(|source| GcError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(on_disk_size(&meta))
}
fn dir_size(dir: &Path) -> Result<u64, GcError> {
if !dir.exists() {
return Ok(0);
}
let mut total = std::fs::symlink_metadata(dir).map(|m| on_disk_size(&m)).unwrap_or(0);
for entry in read_dir(dir)? {
let entry = entry.map_err(|source| GcError::Io {
path: dir.to_path_buf(),
source,
})?;
let path = entry.path();
let meta = entry.metadata().map_err(|source| GcError::Io {
path: path.clone(),
source,
})?;
if meta.is_dir() {
total += dir_size(&path)?;
} else {
total += on_disk_size(&meta);
}
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::{FileEntry, INDEX_FILE, Index};
use std::fs;
use std::path::PathBuf;
struct Fixture {
_tmp: tempfile::TempDir,
basemind_dir: PathBuf,
referenced_stem: String,
orphan_stem: String,
orphan_len: u64,
}
fn build_fixture() -> Fixture {
let tmp = tempfile::tempdir().expect("tempdir");
let basemind_dir = tmp.path().join(".basemind");
let blobs = basemind_dir.join(BLOBS_DIR);
let working = basemind_dir.join(VIEWS_DIR).join("working");
fs::create_dir_all(&blobs).expect("mk blobs");
fs::create_dir_all(&working).expect("mk view");
let referenced_stem = "a".repeat(64);
let orphan_stem = "b".repeat(64);
fs::write(blobs.join(format!("{referenced_stem}.fm.msgpack")), b"fm").expect("write ref fm");
let orphan_bytes = b"orphan-blob-bytes";
let orphan_len = orphan_bytes.len() as u64;
fs::write(blobs.join(format!("{orphan_stem}.fm.msgpack")), orphan_bytes).expect("write orphan");
let mut index = Index::empty();
index.files.insert(
crate::path::RelPath::from("src/main.rs"),
FileEntry {
hash_hex: referenced_stem.clone(),
language: "rust".to_string(),
size_bytes: 2,
mtime: 0,
},
);
let bytes = rmp_serde::to_vec_named(&index).expect("encode index");
fs::write(working.join(INDEX_FILE), bytes).expect("write index");
Fixture {
_tmp: tmp,
basemind_dir,
referenced_stem,
orphan_stem,
orphan_len,
}
}
#[test]
fn cache_stats_counts_git_history_and_reconciles_total() {
let fx = build_fixture();
let gh_dir = fx.basemind_dir.join(crate::git_history::GIT_HISTORY_DIR);
fs::create_dir_all(&gh_dir).expect("mk git-history");
let gh_payload = b"git-history-index-bytes-XXXXXXXX";
fs::write(gh_dir.join("commits.fjall"), gh_payload).expect("write gh blob");
let stray = b"lockmeta";
fs::write(fx.basemind_dir.join(".lock.meta"), stray).expect("write stray");
let stats = cache_stats(&fx.basemind_dir).expect("cache_stats");
assert!(
stats.git_history_bytes >= gh_payload.len() as u64,
"git-history.fjall/ must be counted (got {})",
stats.git_history_bytes
);
assert!(
stats.other_bytes >= stray.len() as u64,
"the unattributed stray file lands in other_bytes (got {})",
stats.other_bytes
);
assert_eq!(
stats.total_bytes,
dir_size(&fx.basemind_dir).expect("dir_size"),
"total_bytes is the whole .basemind/ tree"
);
let component_sum = stats.blobs_bytes
+ stats.views_bytes
+ stats.lance_bytes
+ stats.git_cache_bytes
+ stats.telemetry_bytes
+ stats.git_history_bytes;
assert_eq!(
stats.total_bytes,
component_sum + stats.other_bytes,
"components + other must reconcile to total"
);
}
#[test]
fn cache_stats_degrades_when_index_unreadable() {
let fx = build_fixture();
let working = fx.basemind_dir.join(VIEWS_DIR).join("working");
fs::write(working.join(INDEX_FILE), b"\xff\xff not-msgpack \x00").expect("corrupt index");
assert!(
collect_referenced_hashes(&fx.basemind_dir).is_err(),
"an unreadable index must fail the delete-path safety check"
);
let stats = cache_stats(&fx.basemind_dir).expect("cache_stats must not hard-fail");
assert!(
!stats.blob_accounting_ok,
"orphan accounting must be flagged unavailable"
);
assert_eq!(
stats.orphan_blob_count, 0,
"orphan count is 0 (skipped), not a real zero"
);
assert!(stats.blob_count >= 2, "blob files are still counted by size walk");
assert!(stats.total_bytes > 0, "sizes are still reported");
assert_eq!(
stats.total_bytes,
dir_size(&fx.basemind_dir).expect("dir_size"),
"total still reconciles to the tree"
);
}
#[test]
fn should_collect_only_referenced_stem() {
let fx = build_fixture();
let referenced = collect_referenced_hashes(&fx.basemind_dir).expect("collect");
assert_eq!(referenced.len(), 1, "exactly one live stem");
assert!(referenced.contains(&fx.referenced_stem), "live stem present");
assert!(
!referenced.contains(&fx.orphan_stem),
"orphan stem must not be referenced"
);
}
#[test]
fn should_remove_only_orphan_blob() {
let fx = build_fixture();
let referenced = collect_referenced_hashes(&fx.basemind_dir).expect("collect");
let report = gc_blobs(&fx.basemind_dir, &referenced).expect("gc");
assert_eq!(report.scanned, 2, "one ref blob + one orphan inspected");
assert_eq!(report.removed, 1, "only the orphan removed");
assert_eq!(
report.bytes_freed, fx.orphan_len,
"freed bytes equal the orphan's exact length"
);
let blobs = fx.basemind_dir.join(BLOBS_DIR);
assert!(
blobs.join(format!("{}.fm.msgpack", fx.referenced_stem)).exists(),
"referenced filemap survives"
);
assert!(
!blobs.join(format!("{}.fm.msgpack", fx.orphan_stem)).exists(),
"orphan filemap gone"
);
}
#[test]
fn should_reclaim_legacy_split_tier_blobs_even_when_stem_is_referenced() {
let fx = build_fixture();
let blobs = fx.basemind_dir.join(BLOBS_DIR);
fs::write(blobs.join(format!("{}.l1.msgpack", fx.referenced_stem)), b"legacy-l1").expect("write legacy l1");
fs::write(blobs.join(format!("{}.l2.msgpack", fx.referenced_stem)), b"legacy-l2").expect("write legacy l2");
let referenced = collect_referenced_hashes(&fx.basemind_dir).expect("collect");
assert!(
referenced.contains(&fx.referenced_stem),
"stem is referenced by the live index"
);
let report = gc_blobs(&fx.basemind_dir, &referenced).expect("gc");
assert_eq!(report.removed, 3, "two legacy split blobs + the orphan filemap");
assert!(
!blobs.join(format!("{}.l1.msgpack", fx.referenced_stem)).exists(),
"legacy l1 reclaimed despite a referenced stem"
);
assert!(
!blobs.join(format!("{}.l2.msgpack", fx.referenced_stem)).exists(),
"legacy l2 reclaimed despite a referenced stem"
);
assert!(
blobs.join(format!("{}.fm.msgpack", fx.referenced_stem)).exists(),
"the live combined filemap survives"
);
}
#[test]
fn should_report_one_orphan_before_gc_and_zero_after() {
let fx = build_fixture();
let before = cache_stats(&fx.basemind_dir).expect("stats before");
assert_eq!(before.blob_count, 2, "two blob files on disk");
assert_eq!(before.orphan_blob_count, 1, "one orphan before GC");
assert_eq!(
before.per_view_file_count,
vec![("working".to_string(), 1)],
"single working view with one indexed file"
);
run_gc(&fx.basemind_dir).expect("gc");
let after = cache_stats(&fx.basemind_dir).expect("stats after");
assert_eq!(after.blob_count, 1, "orphan reaped");
assert_eq!(after.orphan_blob_count, 0, "no orphans remain");
}
#[test]
fn should_clear_only_blobs_component() {
let fx = build_fixture();
fs::write(fx.basemind_dir.join(TELEMETRY_FILENAME), b"{}\n").expect("telemetry");
clear_component(&fx.basemind_dir, CacheComponent::Blobs).expect("clear blobs");
let blobs = fx.basemind_dir.join(BLOBS_DIR);
let remaining: Vec<_> = fs::read_dir(&blobs)
.expect("read blobs")
.filter_map(Result::ok)
.collect();
assert!(remaining.is_empty(), "blobs dir emptied: {remaining:?}");
assert!(blobs.exists(), "blobs dir itself preserved");
assert!(
fx.basemind_dir
.join(VIEWS_DIR)
.join("working")
.join(INDEX_FILE)
.exists(),
"view index untouched by Blobs clear"
);
assert!(
fx.basemind_dir.join(TELEMETRY_FILENAME).exists(),
"telemetry untouched by Blobs clear"
);
}
fn build_two_view_fixture() -> (tempfile::TempDir, PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let basemind_dir = tmp.path().join(".basemind");
for view in ["working", "rev-abc"] {
let view_dir = basemind_dir.join(VIEWS_DIR).join(view);
fs::create_dir_all(&view_dir).expect("mk view");
let mut index = Index::empty();
index.files.insert(
crate::path::RelPath::from("src/main.rs"),
FileEntry {
hash_hex: "a".repeat(64),
language: "rust".to_string(),
size_bytes: 2,
mtime: 0,
},
);
let bytes = rmp_serde::to_vec_named(&index).expect("encode");
fs::write(view_dir.join(INDEX_FILE), bytes).expect("write index");
}
(tmp, basemind_dir)
}
#[test]
fn should_clear_single_view_and_leave_others_intact() {
let (_tmp, basemind_dir) = build_two_view_fixture();
clear_single_view(&basemind_dir, "rev-abc").expect("clear one view");
assert!(
!basemind_dir.join(VIEWS_DIR).join("rev-abc").exists(),
"named view removed"
);
assert!(
basemind_dir.join(VIEWS_DIR).join("working").join(INDEX_FILE).exists(),
"other view survives single-view clear"
);
}
#[test]
fn clear_single_view_is_idempotent_for_missing_view() {
let (_tmp, basemind_dir) = build_two_view_fixture();
clear_single_view(&basemind_dir, "rev-does-not-exist").expect("missing view is a no-op");
assert!(basemind_dir.join(VIEWS_DIR).join("working").exists());
assert!(basemind_dir.join(VIEWS_DIR).join("rev-abc").exists());
}
#[test]
fn clear_single_view_rejects_path_traversal() {
let (_tmp, basemind_dir) = build_two_view_fixture();
for bad in ["..", "a/b", "../escape", ""] {
assert!(
clear_single_view(&basemind_dir, bad).is_err(),
"invalid view name {bad:?} must be rejected"
);
}
assert!(basemind_dir.join(VIEWS_DIR).join("working").exists());
}
#[test]
fn blob_stem_recovers_stem_for_every_known_suffix() {
assert_eq!(blob_stem("deadbeef.fm.msgpack"), Some("deadbeef"));
assert_eq!(blob_stem("deadbeef.doc.msgpack"), Some("deadbeef"));
assert_eq!(blob_stem("deadbeef.chunk.msgpack"), Some("deadbeef"));
assert_eq!(blob_stem("deadbeef.rref.msgpack"), Some("deadbeef"));
assert_eq!(blob_stem("deadbeef.tmp"), None);
}
#[test]
fn should_reclaim_unreferenced_chunk_and_rref_but_keep_referenced() {
let fx = build_fixture();
let blobs = fx.basemind_dir.join(BLOBS_DIR);
fs::write(
blobs.join(format!("{}.chunk.msgpack", fx.referenced_stem)),
b"ref-chunk",
)
.expect("ref chunk");
fs::write(blobs.join(format!("{}.rref.msgpack", fx.referenced_stem)), b"ref-rref").expect("ref rref");
fs::write(blobs.join(format!("{}.chunk.msgpack", fx.orphan_stem)), b"orphan-chunk").expect("orphan chunk");
fs::write(blobs.join(format!("{}.rref.msgpack", fx.orphan_stem)), b"orphan-rref").expect("orphan rref");
let referenced = collect_referenced_hashes(&fx.basemind_dir).expect("collect");
gc_blobs(&fx.basemind_dir, &referenced).expect("gc");
assert!(
blobs.join(format!("{}.chunk.msgpack", fx.referenced_stem)).exists(),
"referenced chunk survives"
);
assert!(
blobs.join(format!("{}.rref.msgpack", fx.referenced_stem)).exists(),
"referenced rref survives"
);
assert!(
!blobs.join(format!("{}.chunk.msgpack", fx.orphan_stem)).exists(),
"orphan chunk reclaimed"
);
assert!(
!blobs.join(format!("{}.rref.msgpack", fx.orphan_stem)).exists(),
"orphan rref reclaimed"
);
}
#[test]
fn should_round_trip_component_tokens() {
for component in [
CacheComponent::Blobs,
CacheComponent::Views,
CacheComponent::Lance,
CacheComponent::GitCache,
CacheComponent::Telemetry,
CacheComponent::All,
] {
let token = component.as_str();
let parsed: CacheComponent = token.parse().expect("parse token");
assert_eq!(parsed, component, "round-trip {token}");
}
assert!("nonsense".parse::<CacheComponent>().is_err(), "unknown token rejected");
}
}