Skip to main content

bv_core/
manifest.rs

1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::PathBuf;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7
8use bv_types::{Cardinality, TypeRef};
9
10use crate::error::{BvError, Result};
11
12/// Quality and governance tier for a tool in the registry.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum Tier {
16    /// Typed I/O complete, conformance tests pass, from a recognized publisher, actively maintained.
17    Core,
18    /// Typed I/O present (may be partial), basic checks pass.
19    #[default]
20    Community,
21    /// Basic checks pass; may lack typed I/O. Hidden from default search results.
22    Experimental,
23}
24
25impl Tier {
26    pub fn as_str(&self) -> &'static str {
27        match self {
28            Tier::Core => "core",
29            Tier::Community => "community",
30            Tier::Experimental => "experimental",
31        }
32    }
33}
34
35impl fmt::Display for Tier {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        f.write_str(self.as_str())
38    }
39}
40
41/// Structured CUDA version with ordering (`12.1 < 12.4 < 13.0`).
42#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
43pub struct CudaVersion {
44    pub major: u32,
45    pub minor: u32,
46}
47
48impl fmt::Display for CudaVersion {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "{}.{}", self.major, self.minor)
51    }
52}
53
54impl FromStr for CudaVersion {
55    type Err = String;
56
57    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
58        let (maj, min) = s
59            .split_once('.')
60            .ok_or_else(|| format!("expected 'major.minor', got '{s}'"))?;
61        Ok(CudaVersion {
62            major: maj
63                .parse()
64                .map_err(|_| format!("invalid major version '{maj}'"))?,
65            minor: min
66                .parse()
67                .map_err(|_| format!("invalid minor version '{min}'"))?,
68        })
69    }
70}
71
72impl TryFrom<String> for CudaVersion {
73    type Error = String;
74    fn try_from(s: String) -> std::result::Result<Self, Self::Error> {
75        s.parse()
76    }
77}
78
79impl From<CudaVersion> for String {
80    fn from(v: CudaVersion) -> String {
81        v.to_string()
82    }
83}
84
85impl Serialize for CudaVersion {
86    fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
87        s.serialize_str(&self.to_string())
88    }
89}
90
91impl<'de> Deserialize<'de> for CudaVersion {
92    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
93        let s = String::deserialize(d)?;
94        s.parse().map_err(serde::de::Error::custom)
95    }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct GpuSpec {
100    pub required: bool,
101    pub min_vram_gb: Option<u32>,
102    pub cuda_version: Option<CudaVersion>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct HardwareSpec {
107    pub gpu: Option<GpuSpec>,
108    pub cpu_cores: Option<u32>,
109    pub ram_gb: Option<f64>,
110    pub disk_gb: Option<f64>,
111}
112
113impl HardwareSpec {
114    /// Check this manifest's requirements against the host's detected hardware.
115    /// Returns every requirement that is not satisfied.
116    pub fn check_against(
117        &self,
118        detected: &crate::hardware::DetectedHardware,
119    ) -> Vec<crate::hardware::HardwareMismatch> {
120        use crate::hardware::HardwareMismatch;
121        let mut out = Vec::new();
122
123        if let Some(gpu_req) = &self.gpu
124            && gpu_req.required
125        {
126            if detected.gpus.is_empty() {
127                out.push(HardwareMismatch::NoGpu);
128            } else {
129                if let Some(min_vram) = gpu_req.min_vram_gb {
130                    let best_vram_mb = detected.gpus.iter().map(|g| g.vram_mb).max().unwrap_or(0);
131                    let best_vram_gb = (best_vram_mb as f64 / 1024.0).floor() as u32;
132                    if best_vram_gb < min_vram {
133                        out.push(HardwareMismatch::InsufficientVram {
134                            required_gb: min_vram,
135                            available_gb: best_vram_gb,
136                        });
137                    }
138                }
139                if let Some(min_cuda) = &gpu_req.cuda_version {
140                    let best_cuda = detected
141                        .gpus
142                        .iter()
143                        .filter_map(|g| g.cuda_version.as_ref())
144                        .max();
145                    match best_cuda {
146                        None => out.push(HardwareMismatch::NoCuda {
147                            required: min_cuda.clone(),
148                        }),
149                        Some(avail) if avail < min_cuda => {
150                            out.push(HardwareMismatch::CudaTooOld {
151                                required: min_cuda.clone(),
152                                available: avail.clone(),
153                            });
154                        }
155                        _ => {}
156                    }
157                }
158            }
159        }
160
161        if let Some(min_ram) = self.ram_gb {
162            let avail = detected.ram_gb();
163            if avail < min_ram {
164                out.push(HardwareMismatch::InsufficientRam {
165                    required_gb: min_ram,
166                    available_gb: avail,
167                });
168            }
169        }
170
171        if let Some(min_disk) = self.disk_gb {
172            let avail = detected.disk_free_gb();
173            if avail < min_disk {
174                out.push(HardwareMismatch::InsufficientDisk {
175                    required_gb: min_disk,
176                    available_gb: avail,
177                });
178            }
179        }
180
181        out
182    }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct ImageSpec {
187    /// Runtime backend, e.g. `"docker"` or `"apptainer"`.
188    pub backend: String,
189    /// Canonical OCI reference, e.g. `"biocontainers/bwa:0.7.17"`.
190    pub reference: String,
191    /// Optional pinned digest for reproducibility.
192    pub digest: Option<String>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct ReferenceDataSpec {
197    pub id: String,
198    pub version: String,
199    pub required: bool,
200    /// Container path where the dataset directory is mounted read-only.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub mount_path: Option<String>,
203    /// Approximate compressed size in bytes.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub size_bytes: Option<u64>,
206}
207
208/// Typed I/O port declaration for a tool.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct IoSpec {
211    pub name: String,
212    /// Type reference, e.g. `"fasta"` or `"fasta[protein]"`.
213    #[serde(rename = "type")]
214    pub r#type: TypeRef,
215    /// How many values this port accepts.
216    #[serde(default)]
217    pub cardinality: Cardinality,
218    /// Absolute path inside the container where this value is mounted.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub mount: Option<PathBuf>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub description: Option<String>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub default: Option<String>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct EntrypointSpec {
229    pub command: String,
230    pub args_template: Option<String>,
231    #[serde(default)]
232    pub env: BTreeMap<String, String>,
233}
234
235/// Binary names that the tool's container exposes on PATH.
236///
237/// Omitting this block defaults to `exposed = [entrypoint.command]` for
238/// single-binary tools that do not need to declare anything extra.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct BinariesSpec {
241    pub exposed: Vec<String>,
242}
243
244/// Per-tool overrides for `bv conformance`'s smoke check.
245///
246/// The smoke check tries a small set of probe args (`--version`, `-version`,
247/// `--help`, `-h`, `-v`, `version`) against every binary the tool exposes,
248/// and counts a binary as alive if any probe produces output or exits 0.
249/// Most tools don't need a `[tool.smoke]` block at all; this is the escape
250/// hatch for the unusual cases.
251#[derive(Debug, Clone, Default, Serialize, Deserialize)]
252pub struct SmokeSpec {
253    /// Override probe args for specific binaries, e.g. `{ "blastn" = "-version" }`.
254    /// Each value is a single command-line argument (or empty string for "run
255    /// the binary with no args"). When set, only this probe is tried for that
256    /// binary; the default list is bypassed.
257    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
258    pub probes: std::collections::BTreeMap<String, String>,
259    /// Binaries to skip entirely (daemons, "no non-destructive invocation"
260    /// tools, etc.). Listed binaries still appear in `[tool.binaries]` and
261    /// get shims; conformance just doesn't probe them.
262    #[serde(default, skip_serializing_if = "Vec::is_empty")]
263    pub skip: Vec<String>,
264}
265
266#[allow(dead_code)]
267fn default_timeout() -> u64 {
268    60
269}
270
271/// Optional Sigstore/cosign signature metadata.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct SignatureSpec {
274    /// `"sigstore"` to verify the OCI image signature with cosign.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub image: Option<String>,
277    /// `"sigstore"` to verify the manifest's commit signature.
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub manifest: Option<String>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct ToolManifest {
284    pub id: String,
285    pub version: String,
286    pub description: Option<String>,
287    pub homepage: Option<String>,
288    pub license: Option<String>,
289    /// Governance tier. Defaults to `community` for new submissions.
290    #[serde(default)]
291    pub tier: Tier,
292    /// GitHub handles of maintainers, e.g. `"github:alice"`.
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub maintainers: Vec<String>,
295    /// Set to `true` when a tool is superseded or no longer maintained.
296    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
297    pub deprecated: bool,
298    pub image: ImageSpec,
299    pub hardware: HardwareSpec,
300    #[serde(default)]
301    pub reference_data: BTreeMap<String, ReferenceDataSpec>,
302    /// Typed inputs. Optional; manifests without this section parse unchanged.
303    #[serde(default)]
304    pub inputs: Vec<IoSpec>,
305    /// Typed outputs. Optional; manifests without this section parse unchanged.
306    #[serde(default)]
307    pub outputs: Vec<IoSpec>,
308    /// Default invocation. Required unless `[tool.subcommands]` is non-empty;
309    /// see `validate()`. Multi-script tools may omit this entirely.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub entrypoint: Option<EntrypointSpec>,
312    /// Tool-namespaced launchers. Reachable as `bv run <toolid> <name> ...args`.
313    /// Each value is the literal argv prefix; user args are appended verbatim.
314    /// Unlike `[tool.binaries]`, names are not exposed on PATH or in the global
315    /// binary index, so generic names (`train`, `eval`) are safe.
316    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
317    pub subcommands: BTreeMap<String, Vec<String>>,
318    /// Container paths the tool writes to during normal execution and that
319    /// should therefore be bound to writable host directories. Critical on
320    /// apptainer (read-only SIF root), nice-to-have on docker (lets caches
321    /// outlive `docker rm`). Tool authors declare these; users can override
322    /// the host side via `[[cache]]` in `bv.toml`.
323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
324    pub cache_paths: Vec<String>,
325    /// Binary names this tool exposes on PATH inside its container.
326    /// Omit for single-binary tools; defaults to `[entrypoint.command]`.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub binaries: Option<BinariesSpec>,
329    /// Smoke-check overrides; consulted by `bv conformance` for unusual binaries.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub smoke: Option<SmokeSpec>,
332    /// Sigstore / cosign signature declarations.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub signatures: Option<SignatureSpec>,
335}
336
337impl ToolManifest {
338    pub fn has_typed_io(&self) -> bool {
339        !self.inputs.is_empty() || !self.outputs.is_empty()
340    }
341
342    /// Returns the effective list of binary names this tool exposes.
343    ///
344    /// When `[tool.binaries]` is absent, defaults to the entrypoint command's
345    /// basename. Multi-script tools without an entrypoint expose no binaries
346    /// (their subcommands stay namespaced under the tool id).
347    pub fn effective_binaries(&self) -> Vec<&str> {
348        if let Some(b) = &self.binaries {
349            return b.exposed.iter().map(|s| s.as_str()).collect();
350        }
351        let Some(ep) = &self.entrypoint else {
352            return vec![];
353        };
354        let cmd = &ep.command;
355        let name = cmd
356            .rfind('/')
357            .map(|i| &cmd[i + 1..])
358            .unwrap_or(cmd.as_str());
359        vec![name]
360    }
361}
362
363/// Top-level manifest, corresponding to a single `.toml` file in the registry.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct Manifest {
366    pub tool: ToolManifest,
367}
368
369#[derive(Debug)]
370pub struct ValidationError {
371    pub field: String,
372    pub message: String,
373}
374
375impl fmt::Display for ValidationError {
376    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
377        write!(f, "{}: {}", self.field, self.message)
378    }
379}
380
381impl Manifest {
382    pub fn from_toml_str(s: &str) -> Result<Self> {
383        let m: Manifest = toml::from_str(s).map_err(|e| BvError::ManifestParse(e.to_string()))?;
384        m.validate_types()?;
385        Ok(m)
386    }
387
388    pub fn to_toml_string(&self) -> Result<String> {
389        toml::to_string_pretty(self).map_err(|e| BvError::ManifestParse(e.to_string()))
390    }
391
392    /// Validates that all TypeRefs in inputs/outputs exist in the bv-types vocabulary.
393    fn validate_types(&self) -> Result<()> {
394        let t = &self.tool;
395        for (side, specs) in [("inputs", &t.inputs), ("outputs", &t.outputs)] {
396            for spec in specs {
397                let id = spec.r#type.base_id();
398                if bv_types::lookup(id).is_none() {
399                    let suggestion = bv_types::suggest(id)
400                        .map(|s| format!(", did you mean `{s}`?"))
401                        .unwrap_or_default();
402                    return Err(BvError::ManifestParse(format!(
403                        "tool.{side}[{}]: unknown type `{id}`{suggestion}",
404                        spec.name
405                    )));
406                }
407            }
408        }
409        Ok(())
410    }
411
412    /// Returns a list of validation errors, or `Ok(())` if the manifest is valid.
413    pub fn validate(&self) -> std::result::Result<(), Vec<ValidationError>> {
414        let mut errors = Vec::new();
415        let t = &self.tool;
416
417        if t.id.is_empty() {
418            errors.push(ValidationError {
419                field: "tool.id".into(),
420                message: "must not be empty".into(),
421            });
422        }
423        if t.version.is_empty() {
424            errors.push(ValidationError {
425                field: "tool.version".into(),
426                message: "must not be empty".into(),
427            });
428        }
429        if t.image.backend.is_empty() {
430            errors.push(ValidationError {
431                field: "tool.image.backend".into(),
432                message: "must not be empty".into(),
433            });
434        }
435        if t.image.reference.is_empty() {
436            errors.push(ValidationError {
437                field: "tool.image.reference".into(),
438                message: "must not be empty".into(),
439            });
440        }
441        match (&t.entrypoint, t.subcommands.is_empty()) {
442            (None, true) => errors.push(ValidationError {
443                field: "tool.entrypoint".into(),
444                message: "must declare either [tool.entrypoint] or [tool.subcommands]".into(),
445            }),
446            (Some(ep), _) if ep.command.is_empty() => errors.push(ValidationError {
447                field: "tool.entrypoint.command".into(),
448                message: "must not be empty".into(),
449            }),
450            _ => {}
451        }
452
453        for (name, cmd) in &t.subcommands {
454            if name.is_empty() {
455                errors.push(ValidationError {
456                    field: "tool.subcommands".into(),
457                    message: "subcommand name must not be empty".into(),
458                });
459                continue;
460            }
461            if name.starts_with('-') {
462                errors.push(ValidationError {
463                    field: format!("tool.subcommands.{name}"),
464                    message: "subcommand name must not start with '-'".into(),
465                });
466            }
467            if cmd.is_empty() {
468                errors.push(ValidationError {
469                    field: format!("tool.subcommands.{name}"),
470                    message: "command vector must not be empty".into(),
471                });
472            }
473        }
474
475        for spec in &t.inputs {
476            if let Some(mount) = &spec.mount
477                && !mount.is_absolute()
478            {
479                errors.push(ValidationError {
480                    field: format!("tool.inputs[{}].mount", spec.name),
481                    message: "must be an absolute path".into(),
482                });
483            }
484        }
485        for spec in &t.outputs {
486            if let Some(mount) = &spec.mount
487                && !mount.is_absolute()
488            {
489                errors.push(ValidationError {
490                    field: format!("tool.outputs[{}].mount", spec.name),
491                    message: "must be an absolute path".into(),
492                });
493            }
494        }
495
496        if let Some(binaries) = &t.binaries {
497            let mut seen = std::collections::HashSet::new();
498            for name in &binaries.exposed {
499                if !seen.insert(name.as_str()) {
500                    errors.push(ValidationError {
501                        field: "tool.binaries.exposed".into(),
502                        message: format!("duplicate binary name '{name}'"),
503                    });
504                }
505            }
506            if !binaries.exposed.is_empty()
507                && let Some(ep) = &t.entrypoint
508            {
509                let cmd = &ep.command;
510                let basename = cmd.rfind('/').map(|i| &cmd[i + 1..]).unwrap_or(cmd);
511                if !binaries.exposed.iter().any(|b| b == basename) {
512                    errors.push(ValidationError {
513                        field: "tool.binaries.exposed".into(),
514                        message: format!(
515                            "entrypoint command '{basename}' must be listed in exposed"
516                        ),
517                    });
518                }
519            }
520        }
521
522        if errors.is_empty() {
523            Ok(())
524        } else {
525            Err(errors)
526        }
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    const SAMPLE: &str = r#"
535[tool]
536id = "bwa"
537version = "0.7.17"
538description = "BWA short-read aligner"
539homepage = "http://bio-bwa.sourceforge.net/"
540license = "GPL-3.0"
541
542[tool.image]
543backend = "docker"
544reference = "biocontainers/bwa:0.7.17--h5bf99c6_8"
545digest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"
546
547[tool.hardware]
548cpu_cores = 8
549ram_gb = 32.0
550disk_gb = 50.0
551
552[tool.hardware.gpu]
553required = false
554
555[[tool.inputs]]
556name = "reads_r1"
557type = "fastq"
558cardinality = "one"
559description = "Forward reads"
560
561[[tool.inputs]]
562name = "reads_r2"
563type = "fastq"
564cardinality = "optional"
565description = "Reverse reads (paired-end)"
566
567[[tool.outputs]]
568name = "alignment"
569type = "bam"
570description = "Aligned reads"
571
572[tool.entrypoint]
573command = "bwa"
574args_template = "mem -t {cpu_cores} {reference} {reads_r1} {reads_r2}"
575
576[tool.entrypoint.env]
577MALLOC_ARENA_MAX = "4"
578"#;
579
580    const SAMPLE_NO_IO: &str = r#"
581[tool]
582id = "mytool"
583version = "1.0.0"
584
585[tool.image]
586backend = "docker"
587reference = "example/mytool:1.0.0"
588
589[tool.hardware]
590
591[tool.entrypoint]
592command = "mytool"
593"#;
594
595    #[test]
596    fn round_trip() {
597        let manifest = Manifest::from_toml_str(SAMPLE).expect("parse failed");
598        assert_eq!(manifest.tool.id, "bwa");
599        assert_eq!(manifest.tool.version, "0.7.17");
600        assert_eq!(manifest.tool.image.backend, "docker");
601        assert_eq!(manifest.tool.inputs.len(), 2);
602        assert_eq!(manifest.tool.outputs.len(), 1);
603        assert_eq!(manifest.tool.inputs[0].cardinality, Cardinality::One);
604        assert_eq!(manifest.tool.inputs[1].cardinality, Cardinality::Optional);
605
606        let serialised = manifest.to_toml_string().expect("serialise failed");
607        let reparsed = Manifest::from_toml_str(&serialised).expect("reparse failed");
608        assert_eq!(reparsed.tool.id, manifest.tool.id);
609        assert_eq!(reparsed.tool.version, manifest.tool.version);
610    }
611
612    /// Regression: HashMap-backed fields produced non-deterministic TOML
613    /// output, breaking lockfile drift detection. Re-serializing the same
614    /// manifest must always yield identical bytes.
615    #[test]
616    fn to_toml_string_is_deterministic_with_subcommands() {
617        let s = r#"
618[tool]
619id = "multi"
620version = "1.0.0"
621
622[tool.image]
623backend = "docker"
624reference = "example/multi:1.0.0"
625
626[tool.hardware]
627
628[tool.entrypoint]
629command = "main"
630
631[tool.subcommands]
632zebra = ["script_z.py"]
633alpha = ["script_a.py"]
634mango = ["python", "-m", "scripts.mango"]
635beta = ["script_b.py"]
636"#;
637        let m = Manifest::from_toml_str(s).expect("parse");
638        let a = m.to_toml_string().unwrap();
639        // Re-serialize many times to make iteration-order luck unlikely.
640        for _ in 0..32 {
641            assert_eq!(a, m.to_toml_string().unwrap(), "non-deterministic output");
642        }
643        // And the keys must appear in lexicographic order (BTreeMap).
644        let alpha = a.find("alpha = ").unwrap();
645        let beta = a.find("beta = ").unwrap();
646        let mango = a.find("mango = ").unwrap();
647        let zebra = a.find("zebra = ").unwrap();
648        assert!(alpha < beta && beta < mango && mango < zebra);
649    }
650
651    #[test]
652    fn no_io_parses_unchanged() {
653        let m = Manifest::from_toml_str(SAMPLE_NO_IO).expect("parse failed");
654        assert!(m.tool.inputs.is_empty());
655        assert!(m.tool.outputs.is_empty());
656        assert!(!m.tool.has_typed_io());
657    }
658
659    #[test]
660    fn typeref_params_parsed() {
661        let s = r#"
662[tool]
663id = "t"
664version = "1.0.0"
665
666[tool.image]
667backend = "docker"
668reference = "example/t:1.0.0"
669
670[tool.hardware]
671
672[[tool.inputs]]
673name = "seqs"
674type = "fasta[protein]"
675cardinality = "one"
676
677[tool.entrypoint]
678command = "t"
679"#;
680        let m = Manifest::from_toml_str(s).unwrap();
681        assert_eq!(m.tool.inputs[0].r#type.params, vec!["protein"]);
682    }
683
684    #[test]
685    fn unknown_type_error() {
686        let s = r#"
687[tool]
688id = "t"
689version = "1.0.0"
690
691[tool.image]
692backend = "docker"
693reference = "example/t:1.0.0"
694
695[tool.hardware]
696
697[[tool.inputs]]
698name = "seqs"
699type = "protien_fasta"
700cardinality = "one"
701
702[tool.entrypoint]
703command = "t"
704"#;
705        let err = Manifest::from_toml_str(s).unwrap_err();
706        let msg = err.to_string();
707        assert!(msg.contains("unknown type"), "got: {msg}");
708    }
709
710    #[test]
711    fn cuda_version_ordering() {
712        let v12_1: CudaVersion = "12.1".parse().unwrap();
713        let v12_4: CudaVersion = "12.4".parse().unwrap();
714        let v13_0: CudaVersion = "13.0".parse().unwrap();
715        assert!(v12_1 < v12_4);
716        assert!(v12_4 < v13_0);
717        assert_eq!(v12_1, "12.1".parse::<CudaVersion>().unwrap());
718    }
719
720    #[test]
721    fn subcommands_only_parses() {
722        let s = r#"
723[tool]
724id = "genie2"
725version = "1.0.0"
726
727[tool.image]
728backend = "docker"
729reference = "ghcr.io/example/genie2:1.0.0"
730
731[tool.hardware]
732
733[tool.subcommands]
734train                = ["python", "genie/train.py"]
735sample_unconditional = ["python", "genie/sample_unconditional.py"]
736"#;
737        let m = Manifest::from_toml_str(s).unwrap();
738        assert!(m.tool.entrypoint.is_none());
739        assert_eq!(m.tool.subcommands.len(), 2);
740        assert_eq!(
741            m.tool.subcommands.get("train").unwrap(),
742            &vec!["python".to_string(), "genie/train.py".to_string()]
743        );
744        m.validate().expect("subcommand-only manifest is valid");
745        // No entrypoint and no [tool.binaries] => no exposed binaries.
746        assert!(m.tool.effective_binaries().is_empty());
747    }
748
749    #[test]
750    fn validate_requires_entrypoint_or_subcommands() {
751        let s = r#"
752[tool]
753id = "broken"
754version = "1.0.0"
755
756[tool.image]
757backend = "docker"
758reference = "example/broken:1.0.0"
759
760[tool.hardware]
761"#;
762        let m = Manifest::from_toml_str(s).unwrap();
763        let errs = m.validate().unwrap_err();
764        assert!(
765            errs.iter().any(|e| e.field == "tool.entrypoint"),
766            "expected entrypoint-or-subcommands error, got: {errs:?}"
767        );
768    }
769
770    #[test]
771    fn validate_rejects_dash_prefixed_subcommand() {
772        let s = r#"
773[tool]
774id = "t"
775version = "1.0.0"
776
777[tool.image]
778backend = "docker"
779reference = "example/t:1.0.0"
780
781[tool.hardware]
782
783[tool.subcommands]
784"-bad" = ["python", "x.py"]
785"#;
786        let m = Manifest::from_toml_str(s).unwrap();
787        let errs = m.validate().unwrap_err();
788        assert!(errs.iter().any(|e| e.field.contains("-bad")));
789    }
790
791    #[test]
792    fn subcommands_round_trip() {
793        let s = r#"
794[tool]
795id = "t"
796version = "1.0.0"
797
798[tool.image]
799backend = "docker"
800reference = "example/t:1.0.0"
801
802[tool.hardware]
803
804[tool.subcommands]
805go = ["python", "main.py"]
806"#;
807        let m = Manifest::from_toml_str(s).unwrap();
808        let serialised = m.to_toml_string().unwrap();
809        let reparsed = Manifest::from_toml_str(&serialised).unwrap();
810        assert_eq!(reparsed.tool.subcommands.len(), 1);
811    }
812
813    #[test]
814    fn validate_catches_empty_id() {
815        let mut manifest = Manifest::from_toml_str(SAMPLE).unwrap();
816        manifest.tool.id = String::new();
817        let errs = manifest.validate().unwrap_err();
818        assert!(errs.iter().any(|e| e.field == "tool.id"));
819    }
820
821    #[test]
822    fn registry_manifests_parse() {
823        let registry = concat!(env!("CARGO_MANIFEST_DIR"), "/../../bv-registry/tools");
824        let Ok(read) = std::fs::read_dir(registry) else {
825            return;
826        };
827        for entry in read {
828            let tool_dir = entry.unwrap().path();
829            if !tool_dir.is_dir() {
830                continue;
831            }
832            for version_entry in std::fs::read_dir(&tool_dir).unwrap() {
833                let path = version_entry.unwrap().path();
834                if path.extension().is_some_and(|e| e == "toml") {
835                    let s = std::fs::read_to_string(&path)
836                        .unwrap_or_else(|_| panic!("failed to read {}", path.display()));
837                    Manifest::from_toml_str(&s)
838                        .unwrap_or_else(|e| panic!("{}: {e}", path.display()));
839                }
840            }
841        }
842    }
843}