Skip to main content

bv_core/
project.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicU64, Ordering};
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::{BvError, Result};
9use crate::lockfile::Lockfile;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ToolDeclaration {
13    pub id: String,
14    /// SemVer version requirement, e.g. `">=0.7,<1"`. Omitted means "latest".
15    #[serde(default, skip_serializing_if = "String::is_empty")]
16    pub version: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct DataDeclaration {
21    pub id: String,
22    #[serde(default)]
23    pub version: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, Default)]
27pub struct HardwareProfile {
28    pub gpu: Option<bool>,
29    pub cpu_cores: Option<u32>,
30    pub ram_gb: Option<f64>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct ProjectMeta {
35    pub name: String,
36    pub description: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct RegistryConfig {
41    pub url: String,
42}
43
44/// `[runtime]` block in `bv.toml`. Selects the container backend.
45#[derive(Debug, Clone, Serialize, Deserialize, Default)]
46pub struct RuntimeConfig {
47    /// `"docker"`, `"apptainer"`, or `"auto"` (default).
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub backend: Option<String>,
50}
51
52/// A writable cache directory bound into the container at runtime.
53///
54/// Used to persist tool scratch state (model weights, downloaded indices,
55/// etc.) across runs, and to satisfy tools that write inside the image,
56/// which apptainer's read-only SIF would otherwise reject.
57///
58/// ```toml
59/// [[cache]]
60/// match = "*"                       # tool id, or "*" for all tools
61/// container_path = "/cache"
62/// host_path = "~/.cache/bv/{tool}"  # `{tool}` is replaced with the tool id
63/// ```
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct CacheMount {
66    /// Tool id this cache applies to. `"*"` matches every tool.
67    #[serde(rename = "match", default = "default_match")]
68    pub tool_match: String,
69    pub container_path: String,
70    pub host_path: String,
71}
72
73fn default_match() -> String {
74    "*".to_string()
75}
76
77/// Contents of `bv.toml`.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct BvToml {
80    pub project: ProjectMeta,
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub registry: Option<RegistryConfig>,
83    #[serde(default)]
84    pub tools: Vec<ToolDeclaration>,
85    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
86    pub data: BTreeMap<String, DataDeclaration>,
87    #[serde(default, skip_serializing_if = "hardware_profile_is_default")]
88    pub hardware: HardwareProfile,
89    #[serde(default, skip_serializing_if = "runtime_config_is_default")]
90    pub runtime: RuntimeConfig,
91    /// Resolves collisions when two tools expose the same binary name.
92    /// Maps binary name to the tool id that should own the shim.
93    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
94    pub binary_overrides: BTreeMap<String, String>,
95    /// User-declared cache mounts, applied to every `bv run` invocation
96    /// whose tool id matches `match`. Persists scratch state (model weights,
97    /// downloaded indices) across runs. User entries override the host_path
98    /// of any matching cache declared by the tool's manifest.
99    #[serde(default, rename = "cache", skip_serializing_if = "Vec::is_empty")]
100    pub caches: Vec<CacheMount>,
101}
102
103fn runtime_config_is_default(rc: &RuntimeConfig) -> bool {
104    rc.backend.is_none()
105}
106
107fn hardware_profile_is_default(h: &HardwareProfile) -> bool {
108    h.gpu.is_none() && h.cpu_cores.is_none() && h.ram_gb.is_none()
109}
110
111impl BvToml {
112    pub fn from_path(path: &Path) -> Result<Self> {
113        let s = fs::read_to_string(path)?;
114        toml::from_str(&s).map_err(|e| BvError::ManifestParse(e.to_string()))
115    }
116
117    pub fn to_path(&self, path: &Path) -> Result<()> {
118        let s = toml::to_string_pretty(self).map_err(|e| BvError::ManifestParse(e.to_string()))?;
119        atomic_write(path, &s)
120    }
121}
122
123pub struct BvLock;
124
125impl BvLock {
126    pub fn from_path(path: &Path) -> Result<Lockfile> {
127        let s = fs::read_to_string(path)?;
128        Lockfile::from_toml_str(&s)
129    }
130
131    pub fn to_path(lock: &Lockfile, path: &Path) -> Result<()> {
132        let s = lock.to_toml_string()?;
133        atomic_write(path, &s)
134    }
135}
136
137fn atomic_write(path: &Path, content: &str) -> Result<()> {
138    let parent = path.parent().unwrap_or(Path::new("."));
139    let tmp = tmp_path(parent);
140    fs::write(&tmp, content).map_err(|e| {
141        // Best-effort cleanup of the staging file before bubbling up.
142        let _ = fs::remove_file(&tmp);
143        e
144    })?;
145    fs::rename(&tmp, path).map_err(|e| {
146        let _ = fs::remove_file(&tmp);
147        e
148    })?;
149    Ok(())
150}
151
152/// Build a tmp filename unique across processes and concurrent invocations
153/// in the same process. PID disambiguates between processes; an in-process
154/// counter handles fast successive writes; nanoseconds give entropy across
155/// quick reruns by separate `bv` processes that happen to share a PID slot.
156fn tmp_path(dir: &Path) -> PathBuf {
157    static COUNTER: AtomicU64 = AtomicU64::new(0);
158    let pid = std::process::id();
159    let nanos = std::time::SystemTime::now()
160        .duration_since(std::time::UNIX_EPOCH)
161        .map(|d| d.subsec_nanos() as u64)
162        .unwrap_or(0);
163    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
164    dir.join(format!(".bv-tmp-{pid}-{nanos:09}-{seq}"))
165}