Skip to main content

hfx_core/
manifest.rs

1//! HFX dataset manifest types.
2
3use std::str::FromStr;
4
5use crate::geo::BoundingBox;
6use crate::raster::FlowDirEncoding;
7
8/// Graph topology class declared in the manifest.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum Topology {
11    /// Strictly convergent: each atom has at most one downstream neighbor.
12    Tree,
13    /// Directed acyclic graph with possible bifurcations.
14    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/// HFX format version.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub enum FormatVersion {
41    /// HFX specification version 0.1.
42    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/// Coordinate reference system for all HFX vector and raster data.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
66pub enum Crs {
67    /// WGS84 geographic coordinates. The only CRS supported in HFX v0.1.
68    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/// Whether upstream area values are precomputed in catchments.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum UpAreaAvailability {
93    /// `up_area_km2` column is populated for all atoms.
94    Precomputed,
95    /// `up_area_km2` is null; engine computes from graph traversal.
96    NotAvailable,
97}
98
99/// Whether optional raster artifacts are present.
100///
101/// When rasters are present, the flow direction encoding is guaranteed to
102/// be specified — this invariant is encoded in the type system.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104pub enum RasterAvailability {
105    /// Both `flow_dir.tif` and `flow_acc.tif` are present.
106    Present(FlowDirEncoding),
107    /// No rasters. Engine skips raster refinement.
108    Absent,
109}
110
111/// Whether the optional snap artifact is present.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub enum SnapAvailability {
114    /// `snap.parquet` is present. Engine uses tiered snap ranking.
115    Present,
116    /// No snap file. Engine uses point-in-polygon on catchments.
117    Absent,
118}
119
120/// Errors from constructing a [`Manifest`].
121#[derive(Debug, thiserror::Error)]
122pub enum ManifestError {
123    /// Returned when atom count is zero.
124    #[error("atom count must be at least 1")]
125    ZeroAtomCount,
126
127    /// Returned when fabric name is empty.
128    #[error("fabric name must not be empty")]
129    EmptyFabricName,
130
131    /// Returned when adapter version is empty.
132    #[error("adapter version must not be empty")]
133    EmptyAdapterVersion,
134
135    /// Returned when created_at timestamp is empty.
136    #[error("created_at timestamp must not be empty")]
137    EmptyCreatedAt,
138
139    /// Returned when terminal_sink_id is not 0.
140    #[error("terminal_sink_id must be 0, got {value}")]
141    InvalidTerminalSinkId {
142        /// The non-zero value.
143        value: i64,
144    },
145
146    /// Returned when fabric_name contains uppercase characters.
147    #[error("fabric name must be lowercase, got {value:?}")]
148    NonLowercaseFabricName {
149        /// The invalid fabric name.
150        value: String,
151    },
152
153    /// Returned when an unsupported CRS string is provided.
154    #[error("unsupported CRS: {value:?}, expected \"EPSG:4326\"")]
155    UnsupportedCrs {
156        /// The unrecognized CRS string.
157        value: String,
158    },
159
160    /// Returned when an unsupported format version is provided.
161    #[error("unsupported format version: {value:?}, expected \"0.1\"")]
162    UnsupportedFormatVersion {
163        /// The unrecognized version string.
164        value: String,
165    },
166
167    /// Returned when an unsupported topology string is provided.
168    #[error("unsupported topology: {value:?}, expected \"tree\" or \"dag\"")]
169    UnsupportedTopology {
170        /// The unrecognized topology string.
171        value: String,
172    },
173}
174
175/// Non-zero count of catchment atoms in a dataset.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
177pub struct AtomCount(u64);
178
179impl AtomCount {
180    /// Constructs an `AtomCount` from a raw `u64`, rejecting zero.
181    ///
182    /// # Errors
183    ///
184    /// | Variant | Condition |
185    /// |---|---|
186    /// | [`ManifestError::ZeroAtomCount`] | `raw` is 0 |
187    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    /// Returns the raw atom count value.
195    pub fn get(self) -> u64 {
196        self.0
197    }
198}
199
200/// Parsed and validated HFX dataset manifest.
201///
202/// Constructed exclusively via [`ManifestBuilder`]. All required invariants
203/// are enforced at build time.
204#[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    /// Returns the HFX format version declared in this manifest.
225    pub fn format_version(&self) -> FormatVersion {
226        self.format_version
227    }
228
229    /// Returns the source fabric name (e.g. `"example-fabric"`).
230    pub fn fabric_name(&self) -> &str {
231        &self.fabric_name
232    }
233
234    /// Returns the optional fabric version string, if declared.
235    pub fn fabric_version(&self) -> Option<&str> {
236        self.fabric_version.as_deref()
237    }
238
239    /// Returns the optional hierarchical subdivision level of the fabric, if declared.
240    pub fn fabric_level(&self) -> Option<u32> {
241        self.fabric_level
242    }
243
244    /// Returns the coordinate reference system for this dataset.
245    pub fn crs(&self) -> Crs {
246        self.crs
247    }
248
249    /// Returns whether upstream area values are precomputed in this dataset.
250    pub fn up_area(&self) -> UpAreaAvailability {
251        self.up_area
252    }
253
254    /// Returns the raster artifact availability for this dataset.
255    pub fn rasters(&self) -> RasterAvailability {
256        self.rasters
257    }
258
259    /// Returns the snap artifact availability for this dataset.
260    pub fn snap(&self) -> SnapAvailability {
261        self.snap
262    }
263
264    /// Returns the declared graph topology of this dataset.
265    pub fn topology(&self) -> Topology {
266        self.topology
267    }
268
269    /// Returns the terminal sink ID for this dataset (always `0` in v0.1).
270    pub fn terminal_sink_id(&self) -> i64 {
271        self.terminal_sink_id
272    }
273
274    /// Returns the optional region label for this dataset, if declared.
275    pub fn region(&self) -> Option<&str> {
276        self.region.as_deref()
277    }
278
279    /// Returns a reference to the dataset's spatial bounding box.
280    pub fn bbox(&self) -> &BoundingBox {
281        &self.bbox
282    }
283
284    /// Returns the non-zero count of catchment atoms in this dataset.
285    pub fn atom_count(&self) -> AtomCount {
286        self.atom_count
287    }
288
289    /// Returns the ISO 8601 creation timestamp string.
290    ///
291    /// Full timestamp parsing and validation is a validator concern; this
292    /// field is stored as-is from the manifest.
293    pub fn created_at(&self) -> &str {
294        &self.created_at
295    }
296
297    /// Returns the adapter version string that compiled this dataset.
298    pub fn adapter_version(&self) -> &str {
299        &self.adapter_version
300    }
301}
302
303/// Builder for [`Manifest`].
304///
305/// Required fields are supplied to [`ManifestBuilder::new`] and validated
306/// immediately. Optional fields are set via chainable `with_*` methods.
307/// Call [`ManifestBuilder::build`] to produce the final [`Manifest`].
308#[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    /// Creates a new builder, validating all required fields immediately.
329    ///
330    /// # Errors
331    ///
332    /// | Variant | Condition |
333    /// |---|---|
334    /// | [`ManifestError::InvalidTerminalSinkId`] | `terminal_sink_id` is not 0 |
335    /// | [`ManifestError::EmptyFabricName`] | `fabric_name` is empty |
336    /// | [`ManifestError::NonLowercaseFabricName`] | `fabric_name` contains uppercase characters |
337    /// | [`ManifestError::EmptyAdapterVersion`] | `adapter_version` is empty |
338    /// | [`ManifestError::EmptyCreatedAt`] | `created_at` is empty |
339    #[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    /// Declares that `up_area_km2` is precomputed for all atoms in this dataset.
391    pub fn with_up_area(mut self) -> Self {
392        self.up_area = UpAreaAvailability::Precomputed;
393        self
394    }
395
396    /// Declares that `flow_dir.tif` and `flow_acc.tif` are present, using the
397    /// given `encoding` convention.
398    pub fn with_rasters(mut self, encoding: FlowDirEncoding) -> Self {
399        self.rasters = RasterAvailability::Present(encoding);
400        self
401    }
402
403    /// Declares that `snap.parquet` is present for tiered snap ranking.
404    pub fn with_snap(mut self) -> Self {
405        self.snap = SnapAvailability::Present;
406        self
407    }
408
409    /// Sets the optional fabric version string.
410    pub fn with_fabric_version(mut self, v: impl Into<String>) -> Self {
411        self.fabric_version = Some(v.into());
412        self
413    }
414
415    /// Sets the optional hierarchical subdivision level of the source fabric.
416    pub fn with_fabric_level(mut self, level: u32) -> Self {
417        self.fabric_level = Some(level);
418        self
419    }
420
421    /// Sets the optional region label for this dataset.
422    pub fn with_region(mut self, region: impl Into<String>) -> Self {
423        self.region = Some(region.into());
424        self
425    }
426
427    /// Consumes the builder and returns a validated [`Manifest`].
428    ///
429    /// This method is infallible: all validation occurs in [`ManifestBuilder::new`].
430    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    // --- AtomCount ---
481
482    #[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    // --- ManifestBuilder validation ---
501
502    #[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    // --- Minimal manifest defaults ---
609
610    #[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    // --- Optional field builders ---
637
638    #[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    // --- Display / FromStr roundtrips ---
678
679    #[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    // --- FromStr error cases ---
700
701    #[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}