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)]
202pub struct FactoredLayerSpec {
203 pub digest: String,
204 pub size: u64,
205 pub media_type: String,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub conda_package: Option<FactoredCondaPin>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct FactoredCondaPin {
212 pub name: String,
213 pub version: String,
214 pub build: String,
215 pub channel: String,
216 pub sha256: String,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct FactoredSpec {
227 pub spec_path: String,
229 pub image_reference: String,
231 pub image_digest: String,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub repodata_snapshot_digest: Option<String>,
236 #[serde(default, skip_serializing_if = "Vec::is_empty")]
238 pub layers: Vec<FactoredLayerSpec>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct ReferenceDataSpec {
243 pub id: String,
244 pub version: String,
245 pub required: bool,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub mount_path: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub size_bytes: Option<u64>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct IoSpec {
257 pub name: String,
258 #[serde(rename = "type")]
260 pub r#type: TypeRef,
261 #[serde(default)]
263 pub cardinality: Cardinality,
264 #[serde(default = "default_required")]
265 pub required: bool,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub mount: Option<PathBuf>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub description: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub default: Option<String>,
273}
274
275fn default_required() -> bool {
276 false
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct EntrypointSpec {
281 pub command: String,
282 pub args_template: Option<String>,
283 #[serde(default)]
284 pub env: BTreeMap<String, String>,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct BinariesSpec {
293 pub exposed: Vec<String>,
294}
295
296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
304pub struct SmokeSpec {
305 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
310 pub probes: std::collections::BTreeMap<String, String>,
311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
315 pub skip: Vec<String>,
316}
317
318#[allow(dead_code)]
319fn default_timeout() -> u64 {
320 60
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct SignatureSpec {
326 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub image: Option<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub manifest: Option<String>,
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize)]
335pub struct ToolManifest {
336 pub id: String,
337 pub version: String,
338 pub description: Option<String>,
339 pub homepage: Option<String>,
340 pub license: Option<String>,
341 #[serde(default)]
343 pub tier: Tier,
344 #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 pub maintainers: Vec<String>,
347 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
349 pub deprecated: bool,
350 pub image: ImageSpec,
351 pub hardware: HardwareSpec,
352 #[serde(default)]
353 pub reference_data: BTreeMap<String, ReferenceDataSpec>,
354 #[serde(default)]
356 pub inputs: Vec<IoSpec>,
357 #[serde(default)]
359 pub outputs: Vec<IoSpec>,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub entrypoint: Option<EntrypointSpec>,
364 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
369 pub subcommands: BTreeMap<String, Vec<String>>,
370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
376 pub cache_paths: Vec<String>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub binaries: Option<BinariesSpec>,
381 #[serde(default, skip_serializing_if = "Option::is_none")]
383 pub smoke: Option<SmokeSpec>,
384 #[serde(default, skip_serializing_if = "Option::is_none")]
386 pub signatures: Option<SignatureSpec>,
387 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub factored: Option<FactoredSpec>,
392}
393
394impl ToolManifest {
395 pub fn has_typed_io(&self) -> bool {
396 !self.inputs.is_empty() || !self.outputs.is_empty()
397 }
398
399 pub fn effective_binaries(&self) -> Vec<&str> {
405 if let Some(b) = &self.binaries {
406 return b.exposed.iter().map(|s| s.as_str()).collect();
407 }
408 let Some(ep) = &self.entrypoint else {
409 return vec![];
410 };
411 let cmd = &ep.command;
412 let name = cmd
413 .rfind('/')
414 .map(|i| &cmd[i + 1..])
415 .unwrap_or(cmd.as_str());
416 vec![name]
417 }
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct Manifest {
423 pub tool: ToolManifest,
424}
425
426#[derive(Debug)]
427pub struct ValidationError {
428 pub field: String,
429 pub message: String,
430}
431
432impl fmt::Display for ValidationError {
433 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
434 write!(f, "{}: {}", self.field, self.message)
435 }
436}
437
438impl Manifest {
439 pub fn from_toml_str(s: &str) -> Result<Self> {
440 let m: Manifest = toml::from_str(s).map_err(|e| BvError::ManifestParse(e.to_string()))?;
441 m.validate_types()?;
442 if let Err(errs) = m.validate() {
443 let combined = errs
444 .iter()
445 .map(|e| e.to_string())
446 .collect::<Vec<_>>()
447 .join("; ");
448 return Err(BvError::ManifestParse(format!(
449 "manifest validation failed: {combined}"
450 )));
451 }
452 Ok(m)
453 }
454
455 pub fn to_toml_string(&self) -> Result<String> {
456 toml::to_string_pretty(self).map_err(|e| BvError::ManifestParse(e.to_string()))
457 }
458
459 fn validate_types(&self) -> Result<()> {
461 let t = &self.tool;
462 for (side, specs) in [("inputs", &t.inputs), ("outputs", &t.outputs)] {
463 for spec in specs {
464 let id = spec.r#type.base_id();
465 if bv_types::lookup(id).is_none() {
466 let suggestion = bv_types::suggest(id)
467 .map(|s| format!(", did you mean `{s}`?"))
468 .unwrap_or_default();
469 return Err(BvError::ManifestParse(format!(
470 "tool.{side}[{}]: unknown type `{id}`{suggestion}",
471 spec.name
472 )));
473 }
474 }
475 }
476 Ok(())
477 }
478
479 pub fn validate(&self) -> std::result::Result<(), Vec<ValidationError>> {
481 let mut errors = Vec::new();
482 let t = &self.tool;
483
484 if t.id.is_empty() {
485 errors.push(ValidationError {
486 field: "tool.id".into(),
487 message: "must not be empty".into(),
488 });
489 }
490 if t.version.is_empty() {
491 errors.push(ValidationError {
492 field: "tool.version".into(),
493 message: "must not be empty".into(),
494 });
495 }
496 if t.image.backend.is_empty() {
497 errors.push(ValidationError {
498 field: "tool.image.backend".into(),
499 message: "must not be empty".into(),
500 });
501 }
502 if t.image.reference.is_empty() {
503 errors.push(ValidationError {
504 field: "tool.image.reference".into(),
505 message: "must not be empty".into(),
506 });
507 }
508 match (&t.entrypoint, t.subcommands.is_empty()) {
509 (None, true) => errors.push(ValidationError {
510 field: "tool.entrypoint".into(),
511 message: "must declare either [tool.entrypoint] or [tool.subcommands]".into(),
512 }),
513 (Some(ep), _) if ep.command.is_empty() => errors.push(ValidationError {
514 field: "tool.entrypoint.command".into(),
515 message: "must not be empty".into(),
516 }),
517 _ => {}
518 }
519
520 for (name, cmd) in &t.subcommands {
521 if name.is_empty() {
522 errors.push(ValidationError {
523 field: "tool.subcommands".into(),
524 message: "subcommand name must not be empty".into(),
525 });
526 continue;
527 }
528 if name.starts_with('-') {
529 errors.push(ValidationError {
530 field: format!("tool.subcommands.{name}"),
531 message: "subcommand name must not start with '-'".into(),
532 });
533 }
534 if cmd.is_empty() {
535 errors.push(ValidationError {
536 field: format!("tool.subcommands.{name}"),
537 message: "command vector must not be empty".into(),
538 });
539 }
540 }
541
542 for spec in &t.inputs {
543 if let Some(mount) = &spec.mount
544 && !mount.is_absolute()
545 {
546 errors.push(ValidationError {
547 field: format!("tool.inputs[{}].mount", spec.name),
548 message: "must be an absolute path".into(),
549 });
550 }
551 }
552 for spec in &t.outputs {
553 if let Some(mount) = &spec.mount
554 && !mount.is_absolute()
555 {
556 errors.push(ValidationError {
557 field: format!("tool.outputs[{}].mount", spec.name),
558 message: "must be an absolute path".into(),
559 });
560 }
561 }
562
563 if let Some(binaries) = &t.binaries {
564 let mut seen = std::collections::HashSet::new();
565 for name in &binaries.exposed {
566 if !seen.insert(name.as_str()) {
567 errors.push(ValidationError {
568 field: "tool.binaries.exposed".into(),
569 message: format!("duplicate binary name '{name}'"),
570 });
571 }
572 }
573 if !binaries.exposed.is_empty()
574 && let Some(ep) = &t.entrypoint
575 {
576 let cmd = &ep.command;
577 let basename = cmd.rfind('/').map(|i| &cmd[i + 1..]).unwrap_or(cmd);
578 if !binaries.exposed.iter().any(|b| b == basename) {
579 errors.push(ValidationError {
580 field: "tool.binaries.exposed".into(),
581 message: format!(
582 "entrypoint command '{basename}' must be listed in exposed"
583 ),
584 });
585 }
586 }
587 }
588
589 if errors.is_empty() {
590 Ok(())
591 } else {
592 Err(errors)
593 }
594 }
595}
596
597#[cfg(test)]
598mod tests {
599 use super::*;
600
601 const SAMPLE: &str = r#"
602[tool]
603id = "bwa"
604version = "0.7.17"
605description = "BWA short-read aligner"
606homepage = "http://bio-bwa.sourceforge.net/"
607license = "GPL-3.0"
608
609[tool.image]
610backend = "docker"
611reference = "biocontainers/bwa:0.7.17--h5bf99c6_8"
612digest = "sha256:abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890ab"
613
614[tool.hardware]
615cpu_cores = 8
616ram_gb = 32.0
617disk_gb = 50.0
618
619[tool.hardware.gpu]
620required = false
621
622[[tool.inputs]]
623name = "reads_r1"
624type = "fastq"
625cardinality = "one"
626description = "Forward reads"
627
628[[tool.inputs]]
629name = "reads_r2"
630type = "fastq"
631cardinality = "optional"
632description = "Reverse reads (paired-end)"
633
634[[tool.outputs]]
635name = "alignment"
636type = "bam"
637description = "Aligned reads"
638
639[tool.entrypoint]
640command = "bwa"
641args_template = "mem -t {cpu_cores} {reference} {reads_r1} {reads_r2}"
642
643[tool.entrypoint.env]
644MALLOC_ARENA_MAX = "4"
645"#;
646
647 const SAMPLE_NO_IO: &str = r#"
648[tool]
649id = "mytool"
650version = "1.0.0"
651
652[tool.image]
653backend = "docker"
654reference = "example/mytool:1.0.0"
655
656[tool.hardware]
657
658[tool.entrypoint]
659command = "mytool"
660"#;
661
662 #[test]
663 fn round_trip() {
664 let manifest = Manifest::from_toml_str(SAMPLE).expect("parse failed");
665 assert_eq!(manifest.tool.id, "bwa");
666 assert_eq!(manifest.tool.version, "0.7.17");
667 assert_eq!(manifest.tool.image.backend, "docker");
668 assert_eq!(manifest.tool.inputs.len(), 2);
669 assert_eq!(manifest.tool.outputs.len(), 1);
670 assert_eq!(manifest.tool.inputs[0].cardinality, Cardinality::One);
671 assert_eq!(manifest.tool.inputs[1].cardinality, Cardinality::Optional);
672
673 let serialised = manifest.to_toml_string().expect("serialise failed");
674 let reparsed = Manifest::from_toml_str(&serialised).expect("reparse failed");
675 assert_eq!(reparsed.tool.id, manifest.tool.id);
676 assert_eq!(reparsed.tool.version, manifest.tool.version);
677 }
678
679 #[test]
683 fn to_toml_string_is_deterministic_with_subcommands() {
684 let s = r#"
685[tool]
686id = "multi"
687version = "1.0.0"
688
689[tool.image]
690backend = "docker"
691reference = "example/multi:1.0.0"
692
693[tool.hardware]
694
695[tool.entrypoint]
696command = "main"
697
698[tool.subcommands]
699zebra = ["script_z.py"]
700alpha = ["script_a.py"]
701mango = ["python", "-m", "scripts.mango"]
702beta = ["script_b.py"]
703"#;
704 let m = Manifest::from_toml_str(s).expect("parse");
705 let a = m.to_toml_string().unwrap();
706 for _ in 0..32 {
708 assert_eq!(a, m.to_toml_string().unwrap(), "non-deterministic output");
709 }
710 let alpha = a.find("alpha = ").unwrap();
712 let beta = a.find("beta = ").unwrap();
713 let mango = a.find("mango = ").unwrap();
714 let zebra = a.find("zebra = ").unwrap();
715 assert!(alpha < beta && beta < mango && mango < zebra);
716 }
717
718 #[test]
719 fn no_io_parses_unchanged() {
720 let m = Manifest::from_toml_str(SAMPLE_NO_IO).expect("parse failed");
721 assert!(m.tool.inputs.is_empty());
722 assert!(m.tool.outputs.is_empty());
723 assert!(!m.tool.has_typed_io());
724 }
725
726 #[test]
727 fn typeref_params_parsed() {
728 let s = r#"
729[tool]
730id = "t"
731version = "1.0.0"
732
733[tool.image]
734backend = "docker"
735reference = "example/t:1.0.0"
736
737[tool.hardware]
738
739[[tool.inputs]]
740name = "seqs"
741type = "fasta[protein]"
742cardinality = "one"
743
744[tool.entrypoint]
745command = "t"
746"#;
747 let m = Manifest::from_toml_str(s).unwrap();
748 assert_eq!(m.tool.inputs[0].r#type.params, vec!["protein"]);
749 }
750
751 #[test]
752 fn unknown_type_error() {
753 let s = r#"
754[tool]
755id = "t"
756version = "1.0.0"
757
758[tool.image]
759backend = "docker"
760reference = "example/t:1.0.0"
761
762[tool.hardware]
763
764[[tool.inputs]]
765name = "seqs"
766type = "protien_fasta"
767cardinality = "one"
768
769[tool.entrypoint]
770command = "t"
771"#;
772 let err = Manifest::from_toml_str(s).unwrap_err();
773 let msg = err.to_string();
774 assert!(msg.contains("unknown type"), "got: {msg}");
775 }
776
777 #[test]
778 fn cuda_version_ordering() {
779 let v12_1: CudaVersion = "12.1".parse().unwrap();
780 let v12_4: CudaVersion = "12.4".parse().unwrap();
781 let v13_0: CudaVersion = "13.0".parse().unwrap();
782 assert!(v12_1 < v12_4);
783 assert!(v12_4 < v13_0);
784 assert_eq!(v12_1, "12.1".parse::<CudaVersion>().unwrap());
785 }
786
787 #[test]
788 fn subcommands_only_parses() {
789 let s = r#"
790[tool]
791id = "genie2"
792version = "1.0.0"
793
794[tool.image]
795backend = "docker"
796reference = "ghcr.io/example/genie2:1.0.0"
797
798[tool.hardware]
799
800[tool.subcommands]
801train = ["python", "genie/train.py"]
802sample_unconditional = ["python", "genie/sample_unconditional.py"]
803"#;
804 let m = Manifest::from_toml_str(s).unwrap();
805 assert!(m.tool.entrypoint.is_none());
806 assert_eq!(m.tool.subcommands.len(), 2);
807 assert_eq!(
808 m.tool.subcommands.get("train").unwrap(),
809 &vec!["python".to_string(), "genie/train.py".to_string()]
810 );
811 m.validate().expect("subcommand-only manifest is valid");
812 assert!(m.tool.effective_binaries().is_empty());
814 }
815
816 #[test]
817 fn validate_requires_entrypoint_or_subcommands() {
818 let s = r#"
819[tool]
820id = "broken"
821version = "1.0.0"
822
823[tool.image]
824backend = "docker"
825reference = "example/broken:1.0.0"
826
827[tool.hardware]
828"#;
829 let err = Manifest::from_toml_str(s).unwrap_err();
832 assert!(
833 err.to_string().contains("tool.entrypoint"),
834 "expected entrypoint-or-subcommands error, got: {err}"
835 );
836 }
837
838 #[test]
839 fn validate_rejects_dash_prefixed_subcommand() {
840 let s = r#"
841[tool]
842id = "t"
843version = "1.0.0"
844
845[tool.image]
846backend = "docker"
847reference = "example/t:1.0.0"
848
849[tool.hardware]
850
851[tool.subcommands]
852"-bad" = ["python", "x.py"]
853"#;
854 let err = Manifest::from_toml_str(s).unwrap_err();
855 assert!(err.to_string().contains("-bad"), "got: {err}");
856 }
857
858 #[test]
859 fn subcommands_round_trip() {
860 let s = r#"
861[tool]
862id = "t"
863version = "1.0.0"
864
865[tool.image]
866backend = "docker"
867reference = "example/t:1.0.0"
868
869[tool.hardware]
870
871[tool.subcommands]
872go = ["python", "main.py"]
873"#;
874 let m = Manifest::from_toml_str(s).unwrap();
875 let serialised = m.to_toml_string().unwrap();
876 let reparsed = Manifest::from_toml_str(&serialised).unwrap();
877 assert_eq!(reparsed.tool.subcommands.len(), 1);
878 }
879
880 #[test]
881 fn validate_catches_empty_id() {
882 let mut manifest = Manifest::from_toml_str(SAMPLE).unwrap();
883 manifest.tool.id = String::new();
884 let errs = manifest.validate().unwrap_err();
885 assert!(errs.iter().any(|e| e.field == "tool.id"));
886 }
887
888 #[test]
889 fn registry_manifests_parse() {
890 let registry = concat!(env!("CARGO_MANIFEST_DIR"), "/../../bv-registry/tools");
891 let Ok(read) = std::fs::read_dir(registry) else {
892 return;
893 };
894 for entry in read {
895 let tool_dir = entry.unwrap().path();
896 if !tool_dir.is_dir() {
897 continue;
898 }
899 for version_entry in std::fs::read_dir(&tool_dir).unwrap() {
900 let path = version_entry.unwrap().path();
901 if path.extension().is_some_and(|e| e == "toml") {
902 let s = std::fs::read_to_string(&path)
903 .unwrap_or_else(|_| panic!("failed to read {}", path.display()));
904 Manifest::from_toml_str(&s)
905 .unwrap_or_else(|e| panic!("{}: {e}", path.display()));
906 }
907 }
908 }
909 }
910}