use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::{Context, Result};
use bv_core::manifest::ToolManifest;
use bv_core::project::BvToml;
use bv_runtime::Mount;
const APPTAINER_FALLBACK_CACHE_PATHS: &[&str] = &["/cache", "/root/.cache"];
pub fn cache_mounts(
tool_id: &str,
backend: &str,
manifest: &ToolManifest,
bv_toml: Option<&BvToml>,
) -> Result<Vec<Mount>> {
let mut by_container: HashMap<String, PathBuf> = HashMap::new();
let mut order: Vec<String> = Vec::new();
let cache_root = bv_cache_dir()?.join(tool_id);
for cp in &manifest.cache_paths {
let host = cache_root.join(slug_for(cp));
if !by_container.contains_key(cp) {
order.push(cp.clone());
}
by_container.insert(cp.clone(), host);
}
if let Some(toml) = bv_toml {
for entry in &toml.caches {
if !match_tool(&entry.tool_match, tool_id) {
continue;
}
let host = expand_host(&entry.host_path, tool_id)?;
if !by_container.contains_key(&entry.container_path) {
order.push(entry.container_path.clone());
}
by_container.insert(entry.container_path.clone(), host);
}
}
if backend == "apptainer" {
for cp in APPTAINER_FALLBACK_CACHE_PATHS {
if by_container.contains_key(*cp) {
continue;
}
let host = cache_root.join(slug_for(cp));
order.push(cp.to_string());
by_container.insert(cp.to_string(), host);
}
}
let mut out = Vec::with_capacity(order.len());
for cp in order {
let host = by_container.remove(&cp).expect("populated above");
std::fs::create_dir_all(&host)
.with_context(|| format!("failed to create cache dir {}", host.display()))?;
out.push(Mount {
host_path: host,
container_path: PathBuf::from(cp),
read_only: false,
});
}
Ok(out)
}
fn match_tool(pattern: &str, tool: &str) -> bool {
pattern == "*" || pattern == tool
}
fn slug_for(container_path: &str) -> String {
let trimmed = container_path.trim_start_matches('/');
if trimmed.is_empty() {
"root".to_string()
} else {
trimmed.replace('/', "-")
}
}
fn expand_host(template: &str, tool: &str) -> Result<PathBuf> {
let mut s = template.replace("{tool}", tool);
if s == "~" {
s = home_dir()?;
} else if let Some(rest) = s.strip_prefix("~/") {
s = format!("{}/{rest}", home_dir()?);
}
Ok(PathBuf::from(s))
}
fn home_dir() -> Result<String> {
std::env::var("HOME").map_err(|_| anyhow::anyhow!("$HOME is not set"))
}
fn bv_cache_dir() -> Result<PathBuf> {
let base = if let Ok(d) = std::env::var("XDG_CACHE_HOME") {
PathBuf::from(d)
} else {
PathBuf::from(home_dir()?).join(".cache")
};
Ok(base.join("bv"))
}
#[cfg(test)]
mod tests {
use super::*;
use bv_core::manifest::{EntrypointSpec, HardwareSpec, ImageSpec, ToolManifest};
use bv_core::project::{CacheMount, ProjectMeta};
fn manifest_with(cache_paths: Vec<&str>) -> ToolManifest {
ToolManifest {
id: "colabfold".into(),
version: "1.6.0".into(),
description: None,
homepage: None,
license: None,
tier: Default::default(),
maintainers: vec![],
deprecated: false,
image: ImageSpec {
backend: "docker".into(),
reference: "ghcr.io/sokrypton/colabfold:1.6.0-cuda12".into(),
digest: None,
},
hardware: HardwareSpec {
gpu: None,
cpu_cores: None,
ram_gb: None,
disk_gb: None,
},
reference_data: Default::default(),
inputs: vec![],
outputs: vec![],
entrypoint: Some(EntrypointSpec {
command: "colabfold_batch".into(),
args_template: None,
env: Default::default(),
}),
subcommands: Default::default(),
cache_paths: cache_paths.into_iter().map(String::from).collect(),
binaries: None,
smoke: None,
signatures: None,
factored: None,
}
}
fn toml_with(caches: Vec<CacheMount>) -> BvToml {
BvToml {
project: ProjectMeta {
name: "t".into(),
description: None,
},
registry: None,
tools: vec![],
data: Default::default(),
hardware: Default::default(),
runtime: Default::default(),
binary_overrides: Default::default(),
caches,
}
}
fn cache(tool_match: &str, container: &str, host: &str) -> CacheMount {
CacheMount {
tool_match: tool_match.into(),
container_path: container.into(),
host_path: host.into(),
}
}
static HOME_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_temp_home<F: FnOnce()>(f: F) {
let _g = HOME_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let td = tempfile::tempdir().unwrap();
let prev = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", td.path());
}
f();
match prev {
Some(h) => unsafe { std::env::set_var("HOME", h) },
None => unsafe { std::env::remove_var("HOME") },
}
}
#[test]
fn match_star_matches_anything() {
assert!(match_tool("*", "colabfold"));
assert!(match_tool("colabfold", "colabfold"));
assert!(!match_tool("blast", "colabfold"));
}
#[test]
fn host_path_interpolates_tool() {
let p = expand_host("/tmp/{tool}/cache", "colabfold").unwrap();
assert_eq!(p, PathBuf::from("/tmp/colabfold/cache"));
}
#[test]
fn slug_collapses_slashes() {
assert_eq!(slug_for("/cache"), "cache");
assert_eq!(slug_for("/cache/colabfold"), "cache-colabfold");
assert_eq!(slug_for("/root/.cache"), "root-.cache");
}
#[test]
fn manifest_declarations_become_mounts() {
with_temp_home(|| {
let m = manifest_with(vec!["/cache/colabfold"]);
let mounts = cache_mounts("colabfold", "docker", &m, None).unwrap();
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].container_path, PathBuf::from("/cache/colabfold"));
assert!(mounts[0].host_path.ends_with("colabfold/cache-colabfold"));
});
}
#[test]
fn user_overrides_manifest_host_path() {
with_temp_home(|| {
let m = manifest_with(vec!["/cache/colabfold"]);
let toml = toml_with(vec![cache(
"colabfold",
"/cache/colabfold",
"~/shared-cache",
)]);
let mounts = cache_mounts("colabfold", "docker", &m, Some(&toml)).unwrap();
assert_eq!(mounts.len(), 1);
assert_eq!(mounts[0].container_path, PathBuf::from("/cache/colabfold"));
assert!(mounts[0].host_path.ends_with("shared-cache"));
});
}
#[test]
fn apptainer_fallbacks_apply_when_unclaimed() {
with_temp_home(|| {
let m = manifest_with(vec![]);
let mounts = cache_mounts("colabfold", "apptainer", &m, None).unwrap();
let paths: Vec<_> = mounts.iter().map(|m| m.container_path.clone()).collect();
assert!(paths.contains(&PathBuf::from("/cache")));
assert!(paths.contains(&PathBuf::from("/root/.cache")));
});
}
#[test]
fn apptainer_fallbacks_skipped_when_manifest_claims_them() {
with_temp_home(|| {
let m = manifest_with(vec!["/cache"]);
let mounts = cache_mounts("colabfold", "apptainer", &m, None).unwrap();
let cache_count = mounts
.iter()
.filter(|x| x.container_path == std::path::Path::new("/cache"))
.count();
assert_eq!(cache_count, 1);
});
}
#[test]
fn docker_skips_fallbacks() {
with_temp_home(|| {
let m = manifest_with(vec![]);
let mounts = cache_mounts("colabfold", "docker", &m, None).unwrap();
assert!(mounts.is_empty());
});
}
}