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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum Tier {
16 Core,
18 #[default]
20 Community,
21 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#[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 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).round() as u32;
136 if best_vram_gb < min_vram {
137 out.push(HardwareMismatch::InsufficientVram {
138 required_gb: min_vram,
139 available_gb: best_vram_gb,
140 });
141 }
142 }
143 if let Some(min_cuda) = &gpu_req.cuda_version {
144 let best_cuda = detected
145 .gpus
146 .iter()
147 .filter_map(|g| g.cuda_version.as_ref())
148 .max();
149 match best_cuda {
150 None => out.push(HardwareMismatch::NoCuda {
151 required: min_cuda.clone(),
152 }),
153 Some(avail) if avail < min_cuda => {
154 out.push(HardwareMismatch::CudaTooOld {
155 required: min_cuda.clone(),
156 available: avail.clone(),
157 });
158 }
159 _ => {}
160 }
161 }
162 }
163 }
164
165 if let Some(min_ram) = self.ram_gb {
166 let avail = detected.ram_gb();
167 if avail < min_ram {
168 out.push(HardwareMismatch::InsufficientRam {
169 required_gb: min_ram,
170 available_gb: avail,
171 });
172 }
173 }
174
175 if let Some(min_disk) = self.disk_gb {
176 let avail = detected.disk_free_gb();
177 if avail < min_disk {
178 out.push(HardwareMismatch::InsufficientDisk {
179 required_gb: min_disk,
180 available_gb: avail,
181 });
182 }
183 }
184
185 out
186 }
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct ImageSpec {
191 pub backend: String,
193 pub reference: String,
195 pub digest: Option<String>,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ReferenceDataSpec {
201 pub id: String,
202 pub version: String,
203 pub required: bool,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub mount_path: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub size_bytes: Option<u64>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct IoSpec {
215 pub name: String,
216 #[serde(rename = "type")]
218 pub r#type: TypeRef,
219 #[serde(default)]
221 pub cardinality: Cardinality,
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub mount: Option<PathBuf>,
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub description: Option<String>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub default: Option<String>,
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct EntrypointSpec {
233 pub command: String,
234 pub args_template: Option<String>,
235 #[serde(default)]
236 pub env: BTreeMap<String, String>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct BinariesSpec {
245 pub exposed: Vec<String>,
246}
247
248#[derive(Debug, Clone, Default, Serialize, Deserialize)]
256pub struct SmokeSpec {
257 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
262 pub probes: std::collections::BTreeMap<String, String>,
263 #[serde(default, skip_serializing_if = "Vec::is_empty")]
267 pub skip: Vec<String>,
268}
269
270#[allow(dead_code)]
271fn default_timeout() -> u64 {
272 60
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct SignatureSpec {
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub image: Option<String>,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub manifest: Option<String>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287pub struct ToolManifest {
288 pub id: String,
289 pub version: String,
290 pub description: Option<String>,
291 pub homepage: Option<String>,
292 pub license: Option<String>,
293 #[serde(default)]
295 pub tier: Tier,
296 #[serde(default, skip_serializing_if = "Vec::is_empty")]
298 pub maintainers: Vec<String>,
299 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
301 pub deprecated: bool,
302 pub image: ImageSpec,
303 pub hardware: HardwareSpec,
304 #[serde(default)]
305 pub reference_data: BTreeMap<String, ReferenceDataSpec>,
306 #[serde(default)]
308 pub inputs: Vec<IoSpec>,
309 #[serde(default)]
311 pub outputs: Vec<IoSpec>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub entrypoint: Option<EntrypointSpec>,
316 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
321 pub subcommands: BTreeMap<String, Vec<String>>,
322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub cache_paths: Vec<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub binaries: Option<BinariesSpec>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub smoke: Option<SmokeSpec>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub signatures: Option<SignatureSpec>,
339}
340
341impl ToolManifest {
342 pub fn has_typed_io(&self) -> bool {
343 !self.inputs.is_empty() || !self.outputs.is_empty()
344 }
345
346 pub fn effective_binaries(&self) -> Vec<&str> {
352 if let Some(b) = &self.binaries {
353 return b.exposed.iter().map(|s| s.as_str()).collect();
354 }
355 let Some(ep) = &self.entrypoint else {
356 return vec![];
357 };
358 let cmd = &ep.command;
359 let name = cmd
360 .rfind('/')
361 .map(|i| &cmd[i + 1..])
362 .unwrap_or(cmd.as_str());
363 vec![name]
364 }
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct Manifest {
370 pub tool: ToolManifest,
371}
372
373#[derive(Debug)]
374pub struct ValidationError {
375 pub field: String,
376 pub message: String,
377}
378
379impl fmt::Display for ValidationError {
380 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
381 write!(f, "{}: {}", self.field, self.message)
382 }
383}
384
385impl Manifest {
386 pub fn from_toml_str(s: &str) -> Result<Self> {
387 let m: Manifest = toml::from_str(s).map_err(|e| BvError::ManifestParse(e.to_string()))?;
388 m.validate_types()?;
389 if let Err(errs) = m.validate() {
390 let combined = errs
391 .iter()
392 .map(|e| e.to_string())
393 .collect::<Vec<_>>()
394 .join("; ");
395 return Err(BvError::ManifestParse(format!(
396 "manifest validation failed: {combined}"
397 )));
398 }
399 Ok(m)
400 }
401
402 pub fn to_toml_string(&self) -> Result<String> {
403 toml::to_string_pretty(self).map_err(|e| BvError::ManifestParse(e.to_string()))
404 }
405
406 fn validate_types(&self) -> Result<()> {
408 let t = &self.tool;
409 for (side, specs) in [("inputs", &t.inputs), ("outputs", &t.outputs)] {
410 for spec in specs {
411 let id = spec.r#type.base_id();
412 if bv_types::lookup(id).is_none() {
413 let suggestion = bv_types::suggest(id)
414 .map(|s| format!(", did you mean `{s}`?"))
415 .unwrap_or_default();
416 return Err(BvError::ManifestParse(format!(
417 "tool.{side}[{}]: unknown type `{id}`{suggestion}",
418 spec.name
419 )));
420 }
421 }
422 }
423 Ok(())
424 }
425
426 pub fn validate(&self) -> std::result::Result<(), Vec<ValidationError>> {
428 let mut errors = Vec::new();
429 let t = &self.tool;
430
431 if t.id.is_empty() {
432 errors.push(ValidationError {
433 field: "tool.id".into(),
434 message: "must not be empty".into(),
435 });
436 }
437 if t.version.is_empty() {
438 errors.push(ValidationError {
439 field: "tool.version".into(),
440 message: "must not be empty".into(),
441 });
442 }
443 if t.image.backend.is_empty() {
444 errors.push(ValidationError {
445 field: "tool.image.backend".into(),
446 message: "must not be empty".into(),
447 });
448 }
449 if t.image.reference.is_empty() {
450 errors.push(ValidationError {
451 field: "tool.image.reference".into(),
452 message: "must not be empty".into(),
453 });
454 }
455 match (&t.entrypoint, t.subcommands.is_empty()) {
456 (None, true) => errors.push(ValidationError {
457 field: "tool.entrypoint".into(),
458 message: "must declare either [tool.entrypoint] or [tool.subcommands]".into(),
459 }),
460 (Some(ep), _) if ep.command.is_empty() => errors.push(ValidationError {
461 field: "tool.entrypoint.command".into(),
462 message: "must not be empty".into(),
463 }),
464 _ => {}
465 }
466
467 for (name, cmd) in &t.subcommands {
468 if name.is_empty() {
469 errors.push(ValidationError {
470 field: "tool.subcommands".into(),
471 message: "subcommand name must not be empty".into(),
472 });
473 continue;
474 }
475 if name.starts_with('-') {
476 errors.push(ValidationError {
477 field: format!("tool.subcommands.{name}"),
478 message: "subcommand name must not start with '-'".into(),
479 });
480 }
481 if cmd.is_empty() {
482 errors.push(ValidationError {
483 field: format!("tool.subcommands.{name}"),
484 message: "command vector must not be empty".into(),
485 });
486 }
487 }
488
489 for spec in &t.inputs {
490 if let Some(mount) = &spec.mount
491 && !mount.is_absolute()
492 {
493 errors.push(ValidationError {
494 field: format!("tool.inputs[{}].mount", spec.name),
495 message: "must be an absolute path".into(),
496 });
497 }
498 }
499 for spec in &t.outputs {
500 if let Some(mount) = &spec.mount
501 && !mount.is_absolute()
502 {
503 errors.push(ValidationError {
504 field: format!("tool.outputs[{}].mount", spec.name),
505 message: "must be an absolute path".into(),
506 });
507 }
508 }
509
510 if let Some(binaries) = &t.binaries {
511 let mut seen = std::collections::HashSet::new();
512 for name in &binaries.exposed {
513 if !seen.insert(name.as_str()) {
514 errors.push(ValidationError {
515 field: "tool.binaries.exposed".into(),
516 message: format!("duplicate binary name '{name}'"),
517 });
518 }
519 }
520 if !binaries.exposed.is_empty()
521 && let Some(ep) = &t.entrypoint
522 {
523 let cmd = &ep.command;
524 let basename = cmd.rfind('/').map(|i| &cmd[i + 1..]).unwrap_or(cmd);
525 if !binaries.exposed.iter().any(|b| b == basename) {
526 errors.push(ValidationError {
527 field: "tool.binaries.exposed".into(),
528 message: format!(
529 "entrypoint command '{basename}' must be listed in exposed"
530 ),
531 });
532 }
533 }
534 }
535
536 if errors.is_empty() {
537 Ok(())
538 } else {
539 Err(errors)
540 }
541 }
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547
548 const SAMPLE: &str = r#"
549[tool]
550id = "bwa"
551version = "0.7.17"
552description = "BWA short-read aligner"
553homepage = "http://bio-bwa.sourceforge.net/"
554license = "GPL-3.0"
555
556[tool.image]
557backend = "docker"
558reference = "biocontainers/bwa:0.7.17--h5bf99c6_8"
559digest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"
560
561[tool.hardware]
562cpu_cores = 8
563ram_gb = 32.0
564disk_gb = 50.0
565
566[tool.hardware.gpu]
567required = false
568
569[[tool.inputs]]
570name = "reads_r1"
571type = "fastq"
572cardinality = "one"
573description = "Forward reads"
574
575[[tool.inputs]]
576name = "reads_r2"
577type = "fastq"
578cardinality = "optional"
579description = "Reverse reads (paired-end)"
580
581[[tool.outputs]]
582name = "alignment"
583type = "bam"
584description = "Aligned reads"
585
586[tool.entrypoint]
587command = "bwa"
588args_template = "mem -t {cpu_cores} {reference} {reads_r1} {reads_r2}"
589
590[tool.entrypoint.env]
591MALLOC_ARENA_MAX = "4"
592"#;
593
594 const SAMPLE_NO_IO: &str = r#"
595[tool]
596id = "mytool"
597version = "1.0.0"
598
599[tool.image]
600backend = "docker"
601reference = "example/mytool:1.0.0"
602
603[tool.hardware]
604
605[tool.entrypoint]
606command = "mytool"
607"#;
608
609 #[test]
610 fn round_trip() {
611 let manifest = Manifest::from_toml_str(SAMPLE).expect("parse failed");
612 assert_eq!(manifest.tool.id, "bwa");
613 assert_eq!(manifest.tool.version, "0.7.17");
614 assert_eq!(manifest.tool.image.backend, "docker");
615 assert_eq!(manifest.tool.inputs.len(), 2);
616 assert_eq!(manifest.tool.outputs.len(), 1);
617 assert_eq!(manifest.tool.inputs[0].cardinality, Cardinality::One);
618 assert_eq!(manifest.tool.inputs[1].cardinality, Cardinality::Optional);
619
620 let serialised = manifest.to_toml_string().expect("serialise failed");
621 let reparsed = Manifest::from_toml_str(&serialised).expect("reparse failed");
622 assert_eq!(reparsed.tool.id, manifest.tool.id);
623 assert_eq!(reparsed.tool.version, manifest.tool.version);
624 }
625
626 #[test]
630 fn to_toml_string_is_deterministic_with_subcommands() {
631 let s = r#"
632[tool]
633id = "multi"
634version = "1.0.0"
635
636[tool.image]
637backend = "docker"
638reference = "example/multi:1.0.0"
639
640[tool.hardware]
641
642[tool.entrypoint]
643command = "main"
644
645[tool.subcommands]
646zebra = ["script_z.py"]
647alpha = ["script_a.py"]
648mango = ["python", "-m", "scripts.mango"]
649beta = ["script_b.py"]
650"#;
651 let m = Manifest::from_toml_str(s).expect("parse");
652 let a = m.to_toml_string().unwrap();
653 for _ in 0..32 {
655 assert_eq!(a, m.to_toml_string().unwrap(), "non-deterministic output");
656 }
657 let alpha = a.find("alpha = ").unwrap();
659 let beta = a.find("beta = ").unwrap();
660 let mango = a.find("mango = ").unwrap();
661 let zebra = a.find("zebra = ").unwrap();
662 assert!(alpha < beta && beta < mango && mango < zebra);
663 }
664
665 #[test]
666 fn no_io_parses_unchanged() {
667 let m = Manifest::from_toml_str(SAMPLE_NO_IO).expect("parse failed");
668 assert!(m.tool.inputs.is_empty());
669 assert!(m.tool.outputs.is_empty());
670 assert!(!m.tool.has_typed_io());
671 }
672
673 #[test]
674 fn typeref_params_parsed() {
675 let s = r#"
676[tool]
677id = "t"
678version = "1.0.0"
679
680[tool.image]
681backend = "docker"
682reference = "example/t:1.0.0"
683
684[tool.hardware]
685
686[[tool.inputs]]
687name = "seqs"
688type = "fasta[protein]"
689cardinality = "one"
690
691[tool.entrypoint]
692command = "t"
693"#;
694 let m = Manifest::from_toml_str(s).unwrap();
695 assert_eq!(m.tool.inputs[0].r#type.params, vec!["protein"]);
696 }
697
698 #[test]
699 fn unknown_type_error() {
700 let s = r#"
701[tool]
702id = "t"
703version = "1.0.0"
704
705[tool.image]
706backend = "docker"
707reference = "example/t:1.0.0"
708
709[tool.hardware]
710
711[[tool.inputs]]
712name = "seqs"
713type = "protien_fasta"
714cardinality = "one"
715
716[tool.entrypoint]
717command = "t"
718"#;
719 let err = Manifest::from_toml_str(s).unwrap_err();
720 let msg = err.to_string();
721 assert!(msg.contains("unknown type"), "got: {msg}");
722 }
723
724 #[test]
725 fn cuda_version_ordering() {
726 let v12_1: CudaVersion = "12.1".parse().unwrap();
727 let v12_4: CudaVersion = "12.4".parse().unwrap();
728 let v13_0: CudaVersion = "13.0".parse().unwrap();
729 assert!(v12_1 < v12_4);
730 assert!(v12_4 < v13_0);
731 assert_eq!(v12_1, "12.1".parse::<CudaVersion>().unwrap());
732 }
733
734 #[test]
735 fn subcommands_only_parses() {
736 let s = r#"
737[tool]
738id = "genie2"
739version = "1.0.0"
740
741[tool.image]
742backend = "docker"
743reference = "ghcr.io/example/genie2:1.0.0"
744
745[tool.hardware]
746
747[tool.subcommands]
748train = ["python", "genie/train.py"]
749sample_unconditional = ["python", "genie/sample_unconditional.py"]
750"#;
751 let m = Manifest::from_toml_str(s).unwrap();
752 assert!(m.tool.entrypoint.is_none());
753 assert_eq!(m.tool.subcommands.len(), 2);
754 assert_eq!(
755 m.tool.subcommands.get("train").unwrap(),
756 &vec!["python".to_string(), "genie/train.py".to_string()]
757 );
758 m.validate().expect("subcommand-only manifest is valid");
759 assert!(m.tool.effective_binaries().is_empty());
761 }
762
763 #[test]
764 fn validate_requires_entrypoint_or_subcommands() {
765 let s = r#"
766[tool]
767id = "broken"
768version = "1.0.0"
769
770[tool.image]
771backend = "docker"
772reference = "example/broken:1.0.0"
773
774[tool.hardware]
775"#;
776 let err = Manifest::from_toml_str(s).unwrap_err();
779 assert!(
780 err.to_string().contains("tool.entrypoint"),
781 "expected entrypoint-or-subcommands error, got: {err}"
782 );
783 }
784
785 #[test]
786 fn validate_rejects_dash_prefixed_subcommand() {
787 let s = r#"
788[tool]
789id = "t"
790version = "1.0.0"
791
792[tool.image]
793backend = "docker"
794reference = "example/t:1.0.0"
795
796[tool.hardware]
797
798[tool.subcommands]
799"-bad" = ["python", "x.py"]
800"#;
801 let err = Manifest::from_toml_str(s).unwrap_err();
802 assert!(err.to_string().contains("-bad"), "got: {err}");
803 }
804
805 #[test]
806 fn subcommands_round_trip() {
807 let s = r#"
808[tool]
809id = "t"
810version = "1.0.0"
811
812[tool.image]
813backend = "docker"
814reference = "example/t:1.0.0"
815
816[tool.hardware]
817
818[tool.subcommands]
819go = ["python", "main.py"]
820"#;
821 let m = Manifest::from_toml_str(s).unwrap();
822 let serialised = m.to_toml_string().unwrap();
823 let reparsed = Manifest::from_toml_str(&serialised).unwrap();
824 assert_eq!(reparsed.tool.subcommands.len(), 1);
825 }
826
827 #[test]
828 fn validate_catches_empty_id() {
829 let mut manifest = Manifest::from_toml_str(SAMPLE).unwrap();
830 manifest.tool.id = String::new();
831 let errs = manifest.validate().unwrap_err();
832 assert!(errs.iter().any(|e| e.field == "tool.id"));
833 }
834
835 #[test]
836 fn registry_manifests_parse() {
837 let registry = concat!(env!("CARGO_MANIFEST_DIR"), "/../../bv-registry/tools");
838 let Ok(read) = std::fs::read_dir(registry) else {
839 return;
840 };
841 for entry in read {
842 let tool_dir = entry.unwrap().path();
843 if !tool_dir.is_dir() {
844 continue;
845 }
846 for version_entry in std::fs::read_dir(&tool_dir).unwrap() {
847 let path = version_entry.unwrap().path();
848 if path.extension().is_some_and(|e| e == "toml") {
849 let s = std::fs::read_to_string(&path)
850 .unwrap_or_else(|_| panic!("failed to read {}", path.display()));
851 Manifest::from_toml_str(&s)
852 .unwrap_or_else(|e| panic!("{}: {e}", path.display()));
853 }
854 }
855 }
856 }
857}