1use std::str::FromStr;
4
5use crate::geo::BoundingBox;
6use crate::raster::FlowDirEncoding;
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 { value: s.to_owned() }),
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum FormatVersion {
41 V0_1,
43}
44
45impl std::fmt::Display for FormatVersion {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 match self {
48 FormatVersion::V0_1 => write!(f, "0.1"),
49 }
50 }
51}
52
53impl FromStr for FormatVersion {
54 type Err = ManifestError;
55
56 fn from_str(s: &str) -> Result<Self, Self::Err> {
57 match s {
58 "0.1" => Ok(FormatVersion::V0_1),
59 _ => Err(ManifestError::UnsupportedFormatVersion { value: s.to_owned() }),
60 }
61 }
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum Crs {
67 Epsg4326,
69}
70
71impl std::fmt::Display for Crs {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 match self {
74 Crs::Epsg4326 => write!(f, "EPSG:4326"),
75 }
76 }
77}
78
79impl FromStr for Crs {
80 type Err = ManifestError;
81
82 fn from_str(s: &str) -> Result<Self, Self::Err> {
83 match s {
84 "EPSG:4326" => Ok(Crs::Epsg4326),
85 _ => Err(ManifestError::UnsupportedCrs { value: s.to_owned() }),
86 }
87 }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum UpAreaAvailability {
93 Precomputed,
95 NotAvailable,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104pub enum RasterAvailability {
105 Present(FlowDirEncoding),
107 Absent,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub enum SnapAvailability {
114 Present,
116 Absent,
118}
119
120#[derive(Debug, thiserror::Error)]
122pub enum ManifestError {
123 #[error("atom count must be at least 1")]
125 ZeroAtomCount,
126
127 #[error("fabric name must not be empty")]
129 EmptyFabricName,
130
131 #[error("adapter version must not be empty")]
133 EmptyAdapterVersion,
134
135 #[error("created_at timestamp must not be empty")]
137 EmptyCreatedAt,
138
139 #[error("terminal_sink_id must be 0, got {value}")]
141 InvalidTerminalSinkId {
142 value: i64,
144 },
145
146 #[error("fabric name must be lowercase, got {value:?}")]
148 NonLowercaseFabricName {
149 value: String,
151 },
152
153 #[error("unsupported CRS: {value:?}, expected \"EPSG:4326\"")]
155 UnsupportedCrs {
156 value: String,
158 },
159
160 #[error("unsupported format version: {value:?}, expected \"0.1\"")]
162 UnsupportedFormatVersion {
163 value: String,
165 },
166
167 #[error("unsupported topology: {value:?}, expected \"tree\" or \"dag\"")]
169 UnsupportedTopology {
170 value: String,
172 },
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
177pub struct AtomCount(u64);
178
179impl AtomCount {
180 pub fn new(raw: u64) -> Result<Self, ManifestError> {
188 if raw == 0 {
189 return Err(ManifestError::ZeroAtomCount);
190 }
191 Ok(Self(raw))
192 }
193
194 pub fn get(self) -> u64 {
196 self.0
197 }
198}
199
200#[derive(Debug, Clone, PartialEq)]
205pub struct Manifest {
206 format_version: FormatVersion,
207 fabric_name: String,
208 fabric_version: Option<String>,
209 fabric_level: Option<u32>,
210 crs: Crs,
211 up_area: UpAreaAvailability,
212 rasters: RasterAvailability,
213 snap: SnapAvailability,
214 topology: Topology,
215 terminal_sink_id: i64,
216 region: Option<String>,
217 bbox: BoundingBox,
218 atom_count: AtomCount,
219 created_at: String,
220 adapter_version: String,
221}
222
223impl Manifest {
224 pub fn format_version(&self) -> FormatVersion {
226 self.format_version
227 }
228
229 pub fn fabric_name(&self) -> &str {
231 &self.fabric_name
232 }
233
234 pub fn fabric_version(&self) -> Option<&str> {
236 self.fabric_version.as_deref()
237 }
238
239 pub fn fabric_level(&self) -> Option<u32> {
241 self.fabric_level
242 }
243
244 pub fn crs(&self) -> Crs {
246 self.crs
247 }
248
249 pub fn up_area(&self) -> UpAreaAvailability {
251 self.up_area
252 }
253
254 pub fn rasters(&self) -> RasterAvailability {
256 self.rasters
257 }
258
259 pub fn snap(&self) -> SnapAvailability {
261 self.snap
262 }
263
264 pub fn topology(&self) -> Topology {
266 self.topology
267 }
268
269 pub fn terminal_sink_id(&self) -> i64 {
271 self.terminal_sink_id
272 }
273
274 pub fn region(&self) -> Option<&str> {
276 self.region.as_deref()
277 }
278
279 pub fn bbox(&self) -> &BoundingBox {
281 &self.bbox
282 }
283
284 pub fn atom_count(&self) -> AtomCount {
286 self.atom_count
287 }
288
289 pub fn created_at(&self) -> &str {
294 &self.created_at
295 }
296
297 pub fn adapter_version(&self) -> &str {
299 &self.adapter_version
300 }
301}
302
303#[derive(Debug)]
309pub struct ManifestBuilder {
310 format_version: FormatVersion,
311 fabric_name: String,
312 crs: Crs,
313 topology: Topology,
314 terminal_sink_id: i64,
315 bbox: BoundingBox,
316 atom_count: AtomCount,
317 created_at: String,
318 adapter_version: String,
319 up_area: UpAreaAvailability,
320 rasters: RasterAvailability,
321 snap: SnapAvailability,
322 fabric_version: Option<String>,
323 fabric_level: Option<u32>,
324 region: Option<String>,
325}
326
327impl ManifestBuilder {
328 #[allow(clippy::too_many_arguments)]
340 pub fn new(
341 format_version: FormatVersion,
342 fabric_name: impl Into<String>,
343 crs: Crs,
344 topology: Topology,
345 terminal_sink_id: i64,
346 bbox: BoundingBox,
347 atom_count: AtomCount,
348 created_at: impl Into<String>,
349 adapter_version: impl Into<String>,
350 ) -> Result<Self, ManifestError> {
351 let fabric_name = fabric_name.into();
352 let created_at = created_at.into();
353 let adapter_version = adapter_version.into();
354
355 if terminal_sink_id != 0 {
356 return Err(ManifestError::InvalidTerminalSinkId { value: terminal_sink_id });
357 }
358 if fabric_name.is_empty() {
359 return Err(ManifestError::EmptyFabricName);
360 }
361 if fabric_name.chars().any(|c| c.is_uppercase()) {
362 return Err(ManifestError::NonLowercaseFabricName { value: fabric_name });
363 }
364 if adapter_version.is_empty() {
365 return Err(ManifestError::EmptyAdapterVersion);
366 }
367 if created_at.is_empty() {
368 return Err(ManifestError::EmptyCreatedAt);
369 }
370
371 Ok(Self {
372 format_version,
373 fabric_name,
374 crs,
375 topology,
376 terminal_sink_id,
377 bbox,
378 atom_count,
379 created_at,
380 adapter_version,
381 up_area: UpAreaAvailability::NotAvailable,
382 rasters: RasterAvailability::Absent,
383 snap: SnapAvailability::Absent,
384 fabric_version: None,
385 fabric_level: None,
386 region: None,
387 })
388 }
389
390 pub fn with_up_area(mut self) -> Self {
392 self.up_area = UpAreaAvailability::Precomputed;
393 self
394 }
395
396 pub fn with_rasters(mut self, encoding: FlowDirEncoding) -> Self {
399 self.rasters = RasterAvailability::Present(encoding);
400 self
401 }
402
403 pub fn with_snap(mut self) -> Self {
405 self.snap = SnapAvailability::Present;
406 self
407 }
408
409 pub fn with_fabric_version(mut self, v: impl Into<String>) -> Self {
411 self.fabric_version = Some(v.into());
412 self
413 }
414
415 pub fn with_fabric_level(mut self, level: u32) -> Self {
417 self.fabric_level = Some(level);
418 self
419 }
420
421 pub fn with_region(mut self, region: impl Into<String>) -> Self {
423 self.region = Some(region.into());
424 self
425 }
426
427 pub fn build(self) -> Manifest {
431 Manifest {
432 format_version: self.format_version,
433 fabric_name: self.fabric_name,
434 fabric_version: self.fabric_version,
435 fabric_level: self.fabric_level,
436 crs: self.crs,
437 up_area: self.up_area,
438 rasters: self.rasters,
439 snap: self.snap,
440 topology: self.topology,
441 terminal_sink_id: self.terminal_sink_id,
442 region: self.region,
443 bbox: self.bbox,
444 atom_count: self.atom_count,
445 created_at: self.created_at,
446 adapter_version: self.adapter_version,
447 }
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use crate::geo::BoundingBox;
455 use crate::raster::FlowDirEncoding;
456
457 fn test_bbox() -> BoundingBox {
458 BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap()
459 }
460
461 fn test_atom_count(n: u64) -> AtomCount {
462 AtomCount::new(n).unwrap()
463 }
464
465 fn minimal_builder() -> ManifestBuilder {
466 ManifestBuilder::new(
467 FormatVersion::V0_1,
468 "testfabric",
469 Crs::Epsg4326,
470 Topology::Tree,
471 0,
472 test_bbox(),
473 test_atom_count(100),
474 "2026-01-01T00:00:00Z",
475 "hfx-adapter-v1",
476 )
477 .unwrap()
478 }
479
480 #[test]
483 fn atom_count_new_one_succeeds() {
484 let count = AtomCount::new(1).unwrap();
485 assert_eq!(count.get(), 1);
486 }
487
488 #[test]
489 fn atom_count_new_zero_fails_with_zero_atom_count() {
490 let err = AtomCount::new(0).unwrap_err();
491 assert!(matches!(err, ManifestError::ZeroAtomCount));
492 }
493
494 #[test]
495 fn atom_count_new_u64_max_succeeds() {
496 let count = AtomCount::new(u64::MAX).unwrap();
497 assert_eq!(count.get(), u64::MAX);
498 }
499
500 #[test]
503 fn builder_empty_fabric_name_fails() {
504 let err = ManifestBuilder::new(
505 FormatVersion::V0_1,
506 "",
507 Crs::Epsg4326,
508 Topology::Tree,
509 0,
510 test_bbox(),
511 test_atom_count(1),
512 "2026-01-01T00:00:00Z",
513 "v1",
514 )
515 .err()
516 .unwrap();
517 assert!(matches!(err, ManifestError::EmptyFabricName));
518 }
519
520 #[test]
521 fn builder_empty_adapter_version_fails() {
522 let err = ManifestBuilder::new(
523 FormatVersion::V0_1,
524 "testfabric",
525 Crs::Epsg4326,
526 Topology::Tree,
527 0,
528 test_bbox(),
529 test_atom_count(1),
530 "2026-01-01T00:00:00Z",
531 "",
532 )
533 .err()
534 .unwrap();
535 assert!(matches!(err, ManifestError::EmptyAdapterVersion));
536 }
537
538 #[test]
539 fn builder_empty_created_at_fails() {
540 let err = ManifestBuilder::new(
541 FormatVersion::V0_1,
542 "testfabric",
543 Crs::Epsg4326,
544 Topology::Tree,
545 0,
546 test_bbox(),
547 test_atom_count(1),
548 "",
549 "v1",
550 )
551 .err()
552 .unwrap();
553 assert!(matches!(err, ManifestError::EmptyCreatedAt));
554 }
555
556 #[test]
557 fn terminal_sink_id_nonzero_fails() {
558 let err = ManifestBuilder::new(
559 FormatVersion::V0_1,
560 "testfabric",
561 Crs::Epsg4326,
562 Topology::Tree,
563 5,
564 test_bbox(),
565 test_atom_count(1),
566 "2026-01-01T00:00:00Z",
567 "v1",
568 )
569 .err()
570 .unwrap();
571 assert!(matches!(err, ManifestError::InvalidTerminalSinkId { value: 5 }));
572 }
573
574 #[test]
575 fn fabric_name_uppercase_fails() {
576 let err = ManifestBuilder::new(
577 FormatVersion::V0_1,
578 "HydroBASINS",
579 Crs::Epsg4326,
580 Topology::Tree,
581 0,
582 test_bbox(),
583 test_atom_count(1),
584 "2026-01-01T00:00:00Z",
585 "v1",
586 )
587 .err()
588 .unwrap();
589 assert!(matches!(err, ManifestError::NonLowercaseFabricName { .. }));
590 }
591
592 #[test]
593 fn fabric_name_lowercase_succeeds() {
594 let result = ManifestBuilder::new(
595 FormatVersion::V0_1,
596 "testfabric",
597 Crs::Epsg4326,
598 Topology::Tree,
599 0,
600 test_bbox(),
601 test_atom_count(1),
602 "2026-01-01T00:00:00Z",
603 "v1",
604 );
605 assert!(result.is_ok());
606 }
607
608 #[test]
611 fn minimal_manifest_has_expected_defaults() {
612 let manifest = minimal_builder().build();
613
614 assert_eq!(manifest.up_area(), UpAreaAvailability::NotAvailable);
615 assert_eq!(manifest.rasters(), RasterAvailability::Absent);
616 assert_eq!(manifest.snap(), SnapAvailability::Absent);
617 assert_eq!(manifest.format_version(), FormatVersion::V0_1);
618 assert_eq!(manifest.crs(), Crs::Epsg4326);
619 assert_eq!(manifest.fabric_version(), None);
620 assert_eq!(manifest.fabric_level(), None);
621 assert_eq!(manifest.region(), None);
622 }
623
624 #[test]
625 fn crs_getter_returns_enum() {
626 let manifest = minimal_builder().build();
627 assert_eq!(manifest.crs(), Crs::Epsg4326);
628 }
629
630 #[test]
631 fn terminal_sink_id_getter_returns_zero() {
632 let manifest = minimal_builder().build();
633 assert_eq!(manifest.terminal_sink_id(), 0);
634 }
635
636 #[test]
639 fn with_up_area_sets_precomputed() {
640 let manifest = minimal_builder().with_up_area().build();
641 assert_eq!(manifest.up_area(), UpAreaAvailability::Precomputed);
642 }
643
644 #[test]
645 fn with_rasters_esri_sets_present_esri() {
646 let manifest = minimal_builder().with_rasters(FlowDirEncoding::Esri).build();
647 assert_eq!(manifest.rasters(), RasterAvailability::Present(FlowDirEncoding::Esri));
648 }
649
650 #[test]
651 fn with_snap_sets_present() {
652 let manifest = minimal_builder().with_snap().build();
653 assert_eq!(manifest.snap(), SnapAvailability::Present);
654 }
655
656 #[test]
657 fn all_optional_fields_set_come_through() {
658 let manifest = minimal_builder()
659 .with_up_area()
660 .with_rasters(FlowDirEncoding::Taudem)
661 .with_snap()
662 .with_fabric_version("v2024")
663 .with_fabric_level(8)
664 .with_region("North America")
665 .build();
666
667 assert_eq!(manifest.up_area(), UpAreaAvailability::Precomputed);
668 assert_eq!(manifest.rasters(), RasterAvailability::Present(FlowDirEncoding::Taudem));
669 assert_eq!(manifest.snap(), SnapAvailability::Present);
670 assert_eq!(manifest.fabric_version(), Some("v2024"));
671 assert_eq!(manifest.fabric_level(), Some(8));
672 assert_eq!(manifest.region(), Some("North America"));
673 assert_eq!(manifest.format_version(), FormatVersion::V0_1);
674 assert_eq!(manifest.crs(), Crs::Epsg4326);
675 }
676
677 #[test]
680 fn topology_display_roundtrip() {
681 assert_eq!(Topology::Tree.to_string(), "tree");
682 assert_eq!(Topology::Dag.to_string(), "dag");
683 assert_eq!("tree".parse::<Topology>().unwrap(), Topology::Tree);
684 assert_eq!("dag".parse::<Topology>().unwrap(), Topology::Dag);
685 }
686
687 #[test]
688 fn format_version_display_roundtrip() {
689 assert_eq!(FormatVersion::V0_1.to_string(), "0.1");
690 assert_eq!("0.1".parse::<FormatVersion>().unwrap(), FormatVersion::V0_1);
691 }
692
693 #[test]
694 fn crs_display_roundtrip() {
695 assert_eq!(Crs::Epsg4326.to_string(), "EPSG:4326");
696 assert_eq!("EPSG:4326".parse::<Crs>().unwrap(), Crs::Epsg4326);
697 }
698
699 #[test]
702 fn topology_fromstr_invalid() {
703 let err = "invalid".parse::<Topology>().unwrap_err();
704 assert!(matches!(err, ManifestError::UnsupportedTopology { .. }));
705 }
706
707 #[test]
708 fn crs_fromstr_invalid() {
709 let err = "EPSG:32632".parse::<Crs>().unwrap_err();
710 assert!(matches!(err, ManifestError::UnsupportedCrs { .. }));
711 }
712
713 #[test]
714 fn format_version_fromstr_invalid() {
715 let err = "0.2".parse::<FormatVersion>().unwrap_err();
716 assert!(matches!(err, ManifestError::UnsupportedFormatVersion { .. }));
717 }
718}