Skip to main content

bv_core/
project.rs

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