1use std::str::FromStr;
4
5use crate::auxiliary::AuxiliaryDecl;
6use crate::geo::BoundingBox;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum Topology {
11 Tree,
13 Dag,
15}
16
17impl std::fmt::Display for Topology {
18 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19 match self {
20 Topology::Tree => write!(f, "tree"),
21 Topology::Dag => write!(f, "dag"),
22 }
23 }
24}
25
26impl FromStr for Topology {
27 type Err = ManifestError;
28
29 fn from_str(s: &str) -> Result<Self, Self::Err> {
30 match s {
31 "tree" => Ok(Topology::Tree),
32 "dag" => Ok(Topology::Dag),
33 _ => Err(ManifestError::UnsupportedTopology {
34 value: s.to_owned(),
35 }),
36 }
37 }
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum FormatVersion {
43 V0_1,
45 V0_2_1,
47}
48
49impl std::fmt::Display for FormatVersion {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 FormatVersion::V0_1 => write!(f, "0.1"),
53 FormatVersion::V0_2_1 => write!(f, "0.2.1"),
54 }
55 }
56}
57
58impl FromStr for FormatVersion {
59 type Err = ManifestError;
60
61 fn from_str(s: &str) -> Result<Self, Self::Err> {
62 match s {
63 "0.1" => Ok(FormatVersion::V0_1),
64 "0.2.1" => Ok(FormatVersion::V0_2_1),
65 _ => Err(ManifestError::UnsupportedFormatVersion {
66 value: s.to_owned(),
67 }),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum Crs {
75 Epsg4326,
77}
78
79impl std::fmt::Display for Crs {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 Crs::Epsg4326 => write!(f, "EPSG:4326"),
83 }
84 }
85}
86
87impl FromStr for Crs {
88 type Err = ManifestError;
89
90 fn from_str(s: &str) -> Result<Self, Self::Err> {
91 match s {
92 "EPSG:4326" => Ok(Crs::Epsg4326),
93 _ => Err(ManifestError::UnsupportedCrs {
94 value: s.to_owned(),
95 }),
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum UpAreaAvailability {
103 Precomputed,
105 NotAvailable,
107}
108
109#[derive(Debug, thiserror::Error)]
111pub enum ManifestError {
112 #[error("unit count must be at least 1")]
114 ZeroUnitCount,
115
116 #[error("fabric name must not be empty")]
118 EmptyFabricName,
119
120 #[error("adapter version must not be empty")]
122 EmptyAdapterVersion,
123
124 #[error("created_at timestamp must not be empty")]
126 EmptyCreatedAt,
127
128 #[error("fabric name must be lowercase, got {value:?}")]
130 NonLowercaseFabricName {
131 value: String,
133 },
134
135 #[error("unsupported CRS: {value:?}, expected \"EPSG:4326\"")]
137 UnsupportedCrs {
138 value: String,
140 },
141
142 #[error("unsupported format version: {value:?}, expected \"0.2.1\"")]
144 UnsupportedFormatVersion {
145 value: String,
147 },
148
149 #[error("unsupported topology: {value:?}, expected \"tree\" or \"dag\"")]
151 UnsupportedTopology {
152 value: String,
154 },
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
159pub struct UnitCount(u64);
160
161impl UnitCount {
162 pub fn new(raw: u64) -> Result<Self, ManifestError> {
170 if raw == 0 {
171 return Err(ManifestError::ZeroUnitCount);
172 }
173 Ok(Self(raw))
174 }
175
176 pub fn get(self) -> u64 {
178 self.0
179 }
180}
181
182#[derive(Debug, Clone, PartialEq)]
187pub struct Manifest {
188 format_version: FormatVersion,
189 fabric_name: String,
190 fabric_version: Option<String>,
191 crs: Crs,
192 up_area: UpAreaAvailability,
193 topology: Topology,
194 region: Option<String>,
195 bbox: BoundingBox,
196 unit_count: UnitCount,
197 created_at: String,
198 adapter_version: String,
199 auxiliary: Vec<AuxiliaryDecl>,
200}
201
202impl Manifest {
203 pub fn format_version(&self) -> FormatVersion {
205 self.format_version
206 }
207
208 pub fn fabric_name(&self) -> &str {
210 &self.fabric_name
211 }
212
213 pub fn fabric_version(&self) -> Option<&str> {
215 self.fabric_version.as_deref()
216 }
217
218 pub fn crs(&self) -> Crs {
220 self.crs
221 }
222
223 pub fn up_area(&self) -> UpAreaAvailability {
225 self.up_area
226 }
227
228 pub fn topology(&self) -> Topology {
230 self.topology
231 }
232
233 pub fn region(&self) -> Option<&str> {
235 self.region.as_deref()
236 }
237
238 pub fn bbox(&self) -> &BoundingBox {
240 &self.bbox
241 }
242
243 pub fn unit_count(&self) -> UnitCount {
245 self.unit_count
246 }
247
248 pub fn created_at(&self) -> &str {
253 &self.created_at
254 }
255
256 pub fn adapter_version(&self) -> &str {
258 &self.adapter_version
259 }
260
261 pub fn auxiliary(&self) -> &[AuxiliaryDecl] {
263 &self.auxiliary
264 }
265}
266
267#[derive(Debug)]
273pub struct ManifestBuilder {
274 format_version: FormatVersion,
275 fabric_name: String,
276 crs: Crs,
277 topology: Topology,
278 bbox: BoundingBox,
279 unit_count: UnitCount,
280 created_at: String,
281 adapter_version: String,
282 up_area: UpAreaAvailability,
283 fabric_version: Option<String>,
284 region: Option<String>,
285 auxiliary: Vec<AuxiliaryDecl>,
286}
287
288impl ManifestBuilder {
289 #[allow(clippy::too_many_arguments)]
300 pub fn new(
301 format_version: FormatVersion,
302 fabric_name: impl Into<String>,
303 crs: Crs,
304 topology: Topology,
305 bbox: BoundingBox,
306 unit_count: UnitCount,
307 created_at: impl Into<String>,
308 adapter_version: impl Into<String>,
309 ) -> Result<Self, ManifestError> {
310 let fabric_name = fabric_name.into();
311 let created_at = created_at.into();
312 let adapter_version = adapter_version.into();
313
314 if fabric_name.is_empty() {
315 return Err(ManifestError::EmptyFabricName);
316 }
317 if fabric_name.chars().any(|c| c.is_uppercase()) {
318 return Err(ManifestError::NonLowercaseFabricName { value: fabric_name });
319 }
320 if adapter_version.is_empty() {
321 return Err(ManifestError::EmptyAdapterVersion);
322 }
323 if created_at.is_empty() {
324 return Err(ManifestError::EmptyCreatedAt);
325 }
326
327 Ok(Self {
328 format_version,
329 fabric_name,
330 crs,
331 topology,
332 bbox,
333 unit_count,
334 created_at,
335 adapter_version,
336 up_area: UpAreaAvailability::NotAvailable,
337 fabric_version: None,
338 region: None,
339 auxiliary: Vec::new(),
340 })
341 }
342
343 pub fn with_up_area(mut self) -> Self {
345 self.up_area = UpAreaAvailability::Precomputed;
346 self
347 }
348
349 pub fn with_fabric_version(mut self, v: impl Into<String>) -> Self {
351 self.fabric_version = Some(v.into());
352 self
353 }
354
355 pub fn with_region(mut self, region: impl Into<String>) -> Self {
357 self.region = Some(region.into());
358 self
359 }
360
361 pub fn with_auxiliary(mut self, auxiliary: AuxiliaryDecl) -> Self {
363 self.auxiliary.push(auxiliary);
364 self
365 }
366
367 pub fn build(self) -> Manifest {
371 Manifest {
372 format_version: self.format_version,
373 fabric_name: self.fabric_name,
374 fabric_version: self.fabric_version,
375 crs: self.crs,
376 up_area: self.up_area,
377 topology: self.topology,
378 region: self.region,
379 bbox: self.bbox,
380 unit_count: self.unit_count,
381 created_at: self.created_at,
382 adapter_version: self.adapter_version,
383 auxiliary: self.auxiliary,
384 }
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::auxiliary::{AuxiliarySchemaId, BlessedAuxSchema};
392 use crate::geo::BoundingBox;
393 use std::collections::BTreeMap;
394
395 fn test_bbox() -> BoundingBox {
396 BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap()
397 }
398
399 fn test_unit_count(n: u64) -> UnitCount {
400 UnitCount::new(n).unwrap()
401 }
402
403 fn minimal_builder() -> ManifestBuilder {
404 ManifestBuilder::new(
405 FormatVersion::V0_2_1,
406 "testfabric",
407 Crs::Epsg4326,
408 Topology::Tree,
409 test_bbox(),
410 test_unit_count(100),
411 "2026-01-01T00:00:00Z",
412 "hfx-adapter-v1",
413 )
414 .unwrap()
415 }
416
417 #[test]
420 fn unit_count_new_one_succeeds() {
421 let count = UnitCount::new(1).unwrap();
422 assert_eq!(count.get(), 1);
423 }
424
425 #[test]
426 fn unit_count_new_zero_fails_with_zero_unit_count() {
427 let err = UnitCount::new(0).unwrap_err();
428 assert!(matches!(err, ManifestError::ZeroUnitCount));
429 }
430
431 #[test]
432 fn unit_count_new_u64_max_succeeds() {
433 let count = UnitCount::new(u64::MAX).unwrap();
434 assert_eq!(count.get(), u64::MAX);
435 }
436
437 #[test]
440 fn builder_empty_fabric_name_fails() {
441 let err = ManifestBuilder::new(
442 FormatVersion::V0_2_1,
443 "",
444 Crs::Epsg4326,
445 Topology::Tree,
446 test_bbox(),
447 test_unit_count(1),
448 "2026-01-01T00:00:00Z",
449 "v1",
450 )
451 .err()
452 .unwrap();
453 assert!(matches!(err, ManifestError::EmptyFabricName));
454 }
455
456 #[test]
457 fn builder_empty_adapter_version_fails() {
458 let err = ManifestBuilder::new(
459 FormatVersion::V0_2_1,
460 "testfabric",
461 Crs::Epsg4326,
462 Topology::Tree,
463 test_bbox(),
464 test_unit_count(1),
465 "2026-01-01T00:00:00Z",
466 "",
467 )
468 .err()
469 .unwrap();
470 assert!(matches!(err, ManifestError::EmptyAdapterVersion));
471 }
472
473 #[test]
474 fn builder_empty_created_at_fails() {
475 let err = ManifestBuilder::new(
476 FormatVersion::V0_2_1,
477 "testfabric",
478 Crs::Epsg4326,
479 Topology::Tree,
480 test_bbox(),
481 test_unit_count(1),
482 "",
483 "v1",
484 )
485 .err()
486 .unwrap();
487 assert!(matches!(err, ManifestError::EmptyCreatedAt));
488 }
489
490 #[test]
491 fn fabric_name_uppercase_fails() {
492 let err = ManifestBuilder::new(
493 FormatVersion::V0_2_1,
494 "HydroBASINS",
495 Crs::Epsg4326,
496 Topology::Tree,
497 test_bbox(),
498 test_unit_count(1),
499 "2026-01-01T00:00:00Z",
500 "v1",
501 )
502 .err()
503 .unwrap();
504 assert!(matches!(err, ManifestError::NonLowercaseFabricName { .. }));
505 }
506
507 #[test]
508 fn fabric_name_lowercase_succeeds() {
509 let result = ManifestBuilder::new(
510 FormatVersion::V0_2_1,
511 "testfabric",
512 Crs::Epsg4326,
513 Topology::Tree,
514 test_bbox(),
515 test_unit_count(1),
516 "2026-01-01T00:00:00Z",
517 "v1",
518 );
519 assert!(result.is_ok());
520 }
521
522 #[test]
525 fn minimal_manifest_has_expected_defaults() {
526 let manifest = minimal_builder().build();
527
528 assert_eq!(manifest.up_area(), UpAreaAvailability::NotAvailable);
529 assert_eq!(manifest.format_version(), FormatVersion::V0_2_1);
530 assert_eq!(manifest.crs(), Crs::Epsg4326);
531 assert_eq!(manifest.fabric_version(), None);
532 assert_eq!(manifest.region(), None);
533 assert_eq!(manifest.auxiliary(), &[]);
534 }
535
536 #[test]
537 fn crs_getter_returns_enum() {
538 let manifest = minimal_builder().build();
539 assert_eq!(manifest.crs(), Crs::Epsg4326);
540 }
541
542 #[test]
543 fn unit_count_getter_returns_unit_count() {
544 let manifest = minimal_builder().build();
545 assert_eq!(manifest.unit_count(), test_unit_count(100));
546 }
547
548 #[test]
551 fn with_up_area_sets_precomputed() {
552 let manifest = minimal_builder().with_up_area().build();
553 assert_eq!(manifest.up_area(), UpAreaAvailability::Precomputed);
554 }
555
556 #[test]
557 fn all_optional_fields_set_come_through() {
558 let mut artifacts = BTreeMap::new();
559 artifacts.insert("flow_dir".to_string(), "flow_dir.tif".to_string());
560 artifacts.insert("flow_acc".to_string(), "flow_acc.tif".to_string());
561 let auxiliary = AuxiliaryDecl::new(
562 AuxiliarySchemaId::Blessed(BlessedAuxSchema::D8RasterV1),
563 artifacts,
564 )
565 .unwrap();
566
567 let manifest = minimal_builder()
568 .with_up_area()
569 .with_fabric_version("v2024")
570 .with_region("North America")
571 .with_auxiliary(auxiliary.clone())
572 .build();
573
574 assert_eq!(manifest.up_area(), UpAreaAvailability::Precomputed);
575 assert_eq!(manifest.fabric_version(), Some("v2024"));
576 assert_eq!(manifest.region(), Some("North America"));
577 assert_eq!(manifest.auxiliary(), &[auxiliary]);
578 assert_eq!(manifest.format_version(), FormatVersion::V0_2_1);
579 assert_eq!(manifest.crs(), Crs::Epsg4326);
580 }
581
582 #[test]
585 fn topology_display_roundtrip() {
586 assert_eq!(Topology::Tree.to_string(), "tree");
587 assert_eq!(Topology::Dag.to_string(), "dag");
588 assert_eq!("tree".parse::<Topology>().unwrap(), Topology::Tree);
589 assert_eq!("dag".parse::<Topology>().unwrap(), Topology::Dag);
590 }
591
592 #[test]
593 fn format_version_display_roundtrip() {
594 assert_eq!(FormatVersion::V0_1.to_string(), "0.1");
595 assert_eq!("0.1".parse::<FormatVersion>().unwrap(), FormatVersion::V0_1);
596 assert_eq!(FormatVersion::V0_2_1.to_string(), "0.2.1");
597 assert_eq!(
598 "0.2.1".parse::<FormatVersion>().unwrap(),
599 FormatVersion::V0_2_1
600 );
601 }
602
603 #[test]
604 fn crs_display_roundtrip() {
605 assert_eq!(Crs::Epsg4326.to_string(), "EPSG:4326");
606 assert_eq!("EPSG:4326".parse::<Crs>().unwrap(), Crs::Epsg4326);
607 }
608
609 #[test]
612 fn topology_fromstr_invalid() {
613 let err = "invalid".parse::<Topology>().unwrap_err();
614 assert!(matches!(err, ManifestError::UnsupportedTopology { .. }));
615 }
616
617 #[test]
618 fn crs_fromstr_invalid() {
619 let err = "EPSG:32632".parse::<Crs>().unwrap_err();
620 assert!(matches!(err, ManifestError::UnsupportedCrs { .. }));
621 }
622
623 #[test]
624 fn format_version_fromstr_invalid() {
625 let err = "1.0".parse::<FormatVersion>().unwrap_err();
626 assert!(matches!(
627 err,
628 ManifestError::UnsupportedFormatVersion { .. }
629 ));
630 }
631}