use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use tatara_lisp::DeriveTataraDomain;
use crate::SpecError;
use crate::store_layout::ParsedStorePath;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreEntry {
pub path: std::path::PathBuf,
pub parsed: ParsedStorePath,
pub is_directory: bool,
pub file_count: usize,
pub size: u64,
}
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "defstore-inventory-profile")]
pub struct StoreInventoryProfile {
pub name: String,
#[serde(rename = "sourceRoot")]
pub source_root: String,
#[serde(rename = "maxEntries", default)]
pub max_entries: usize,
#[serde(rename = "skipPattern", default)]
pub skip_pattern: String,
#[serde(rename = "computeNarHash", default)]
pub compute_nar_hash: bool,
}
#[derive(Debug, Clone, Default)]
pub struct StoreInventory {
pub root: std::path::PathBuf,
pub entries: BTreeMap<String, StoreEntry>,
}
impl StoreInventory {
pub fn walk(profile: &StoreInventoryProfile) -> Result<Self, SpecError> {
let root = std::path::PathBuf::from(&profile.source_root);
if !root.is_dir() {
return Err(SpecError::Interp {
phase: "inventory-bad-root".into(),
message: format!("not a directory: {}", root.display()),
});
}
let skip = if profile.skip_pattern.is_empty() {
None
} else {
Some(regex::Regex::new(&profile.skip_pattern).map_err(|e| SpecError::Interp {
phase: "inventory-bad-pattern".into(),
message: format!("invalid skip regex: {e}"),
})?)
};
let entries_iter = std::fs::read_dir(&root).map_err(|e| SpecError::Interp {
phase: "inventory-io".into(),
message: format!("read_dir {}: {e}", root.display()),
})?;
let mut entries: BTreeMap<String, StoreEntry> = BTreeMap::new();
for entry in entries_iter.flatten() {
if profile.max_entries > 0 && entries.len() >= profile.max_entries {
break;
}
let name = entry.file_name().to_string_lossy().to_string();
if let Some(skip) = &skip {
if skip.is_match(&name) {
continue;
}
}
let path = entry.path();
let parsed = match crate::store_layout::validate_against_canonical(
&path.to_string_lossy()
) {
Ok(p) => p,
Err(_) => continue, };
let meta = std::fs::symlink_metadata(&path).ok();
let is_directory = meta.as_ref().map(|m| m.is_dir()).unwrap_or(false);
let (file_count, size) = if is_directory {
summarize_tree(&path)
} else {
(1, meta.as_ref().map(|m| m.len()).unwrap_or(0))
};
entries.insert(name.clone(), StoreEntry {
path,
parsed,
is_directory,
file_count,
size,
});
}
Ok(Self { root, entries })
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&StoreEntry> {
self.entries.get(name)
}
pub fn filter_by_name(&self, pattern: &str) -> Result<Vec<&StoreEntry>, SpecError> {
let re = regex::Regex::new(pattern).map_err(|e| SpecError::Interp {
phase: "inventory-bad-pattern".into(),
message: format!("invalid regex: {e}"),
})?;
Ok(self.entries.values()
.filter(|e| re.is_match(&e.parsed.name))
.collect())
}
#[must_use]
pub fn filter_by_size(&self, predicate: impl Fn(u64) -> bool) -> Vec<&StoreEntry> {
self.entries.values().filter(|e| predicate(e.size)).collect()
}
#[must_use]
pub fn total_size(&self) -> u64 {
self.entries.values().map(|e| e.size).sum()
}
#[must_use]
pub fn total_files(&self) -> usize {
self.entries.values().map(|e| e.file_count).sum()
}
}
fn summarize_tree(root: &std::path::Path) -> (usize, u64) {
let mut files = 0usize;
let mut size = 0u64;
fn walk(path: &std::path::Path, files: &mut usize, size: &mut u64) {
let Ok(meta) = std::fs::symlink_metadata(path) else { return; };
if meta.is_file() {
*files += 1;
*size += meta.len();
} else if meta.is_dir() {
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
walk(&entry.path(), files, size);
}
}
}
}
walk(root, &mut files, &mut size);
(files, size)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Closure {
pub root: std::path::PathBuf,
pub paths: BTreeSet<std::path::PathBuf>,
}
impl Closure {
pub fn walk(root: &std::path::Path, store_root: &str) -> Result<Self, SpecError> {
let _ = crate::store_layout::validate_against_canonical(&root.to_string_lossy())
.map_err(|e| SpecError::Interp {
phase: "closure-bad-root".into(),
message: format!("{root}: {e:?}", root = root.display()),
})?;
let mut visited: BTreeSet<std::path::PathBuf> = BTreeSet::new();
let mut queue: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
let max_depth = 4096usize; let mut iters = 0usize;
while let Some(path) = queue.pop() {
iters += 1;
if iters > max_depth { break; }
if !visited.insert(path.clone()) {
continue;
}
let nar = match crate::nar::encode(&path) {
Ok(b) => b,
Err(_) => continue, };
let prefix = format!("{}/", store_root.trim_end_matches('/'));
let prefix_bytes = prefix.as_bytes();
let mut i = 0usize;
while i + prefix_bytes.len() < nar.len() {
if &nar[i..i + prefix_bytes.len()] == prefix_bytes {
let start = i;
let mut j = start + prefix_bytes.len();
while j < nar.len() {
let c = nar[j];
if c.is_ascii_alphanumeric() || c == b'-' || c == b'.' || c == b'+' || c == b'_' {
j += 1;
} else {
break;
}
}
if j > start + prefix_bytes.len() {
let s = std::str::from_utf8(&nar[start..j]).unwrap_or("");
let candidate = std::path::PathBuf::from(s);
if candidate != path
&& crate::store_layout::validate_against_canonical(s).is_ok()
&& !visited.contains(&candidate)
{
queue.push(candidate);
}
}
i = j;
} else {
i += 1;
}
}
}
Ok(Self {
root: root.to_path_buf(),
paths: visited,
})
}
#[must_use]
pub fn len(&self) -> usize { self.paths.len() }
#[must_use]
pub fn is_empty(&self) -> bool { self.paths.is_empty() }
}
#[derive(Debug, Clone, Default)]
pub struct RefIndex {
pub references: BTreeMap<std::path::PathBuf, BTreeSet<std::path::PathBuf>>,
pub referrers: BTreeMap<std::path::PathBuf, BTreeSet<std::path::PathBuf>>,
}
impl RefIndex {
pub fn build(inv: &StoreInventory, store_root: &str) -> Result<Self, SpecError> {
let mut idx = RefIndex::default();
for entry in inv.entries.values() {
let nar = match crate::nar::encode(&entry.path) {
Ok(b) => b,
Err(_) => continue,
};
let mut hits: BTreeSet<std::path::PathBuf> = BTreeSet::new();
scan_refs(&nar, store_root, &entry.path, &mut hits);
for r in &hits {
idx.referrers.entry(r.clone()).or_default().insert(entry.path.clone());
}
idx.references.insert(entry.path.clone(), hits);
}
Ok(idx)
}
#[must_use]
pub fn refs_from(&self, path: &std::path::Path) -> &BTreeSet<std::path::PathBuf> {
static EMPTY: std::sync::OnceLock<BTreeSet<std::path::PathBuf>> = std::sync::OnceLock::new();
self.references.get(path).unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new))
}
#[must_use]
pub fn referrers_of(&self, path: &std::path::Path) -> &BTreeSet<std::path::PathBuf> {
static EMPTY: std::sync::OnceLock<BTreeSet<std::path::PathBuf>> = std::sync::OnceLock::new();
self.referrers.get(path).unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new))
}
}
fn scan_refs(
nar: &[u8],
store_root: &str,
self_path: &std::path::Path,
hits: &mut BTreeSet<std::path::PathBuf>,
) {
let prefix = format!("{}/", store_root.trim_end_matches('/'));
let prefix_bytes = prefix.as_bytes();
let mut i = 0usize;
while i + prefix_bytes.len() < nar.len() {
if &nar[i..i + prefix_bytes.len()] == prefix_bytes {
let start = i;
let mut j = start + prefix_bytes.len();
while j < nar.len() {
let c = nar[j];
if c.is_ascii_alphanumeric() || c == b'-' || c == b'.' || c == b'+' || c == b'_' {
j += 1;
} else {
break;
}
}
if j > start + prefix_bytes.len() {
let s = std::str::from_utf8(&nar[start..j]).unwrap_or("");
let cand = std::path::PathBuf::from(s);
if cand != self_path
&& crate::store_layout::validate_against_canonical(s).is_ok()
{
hits.insert(cand);
}
}
i = j;
} else {
i += 1;
}
}
}
pub const CANONICAL_STORE_INVENTORY_LISP: &str =
include_str!("../specs/store_inventory.lisp");
pub fn load_canonical_profiles() -> Result<Vec<StoreInventoryProfile>, SpecError> {
crate::loader::load_all::<StoreInventoryProfile>(CANONICAL_STORE_INVENTORY_LISP)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_profiles_parse() {
let profiles = load_canonical_profiles().unwrap();
assert!(!profiles.is_empty());
}
#[test]
fn inventory_walk_skips_pattern() {
let tmp = std::env::temp_dir().join("sui-inv-test");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("00000000000000000000000000000000-source"), b"x").unwrap();
std::fs::write(tmp.join("11111111111111111111111111111111-source"), b"y").unwrap();
std::fs::write(tmp.join("22222222222222222222222222222222-skip-me"), b"z").unwrap();
let profile = StoreInventoryProfile {
name: "test".into(),
source_root: tmp.display().to_string(),
max_entries: 0,
skip_pattern: ".*skip-me$".into(),
compute_nar_hash: false,
};
let inv = StoreInventory::walk(&profile);
let _ = inv;
let _ = std::fs::remove_dir_all(&tmp);
}
}