Skip to main content

hfx_core/
manifest.rs

1//! HFX dataset manifest types.
2
3use std::str::FromStr;
4
5use crate::auxiliary::AuxiliaryDecl;
6use crate::geo::BoundingBox;
7
8/// Graph topology class declared in the manifest.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10pub enum Topology {
11    /// Strictly convergent: each unit 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 {
34                value: s.to_owned(),
35            }),
36        }
37    }
38}
39
40/// HFX format version.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum FormatVersion {
43    /// HFX specification version 0.1.
44    V0_1,
45    /// HFX specification version 0.2.1.
46    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/// Coordinate reference system for all HFX vector data.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74pub enum Crs {
75    /// WGS84 geographic coordinates. The only CRS supported in HFX v0.2.1.
76    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/// Whether upstream area values are precomputed in catchments.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
102pub enum UpAreaAvailability {
103    /// `up_area_km2` column is populated for applicable units.
104    Precomputed,
105    /// `up_area_km2` is null; engine computes from graph traversal.
106    NotAvailable,
107}
108
109/// Errors from constructing a [`Manifest`].
110#[derive(Debug, thiserror::Error)]
111pub enum ManifestError {
112    /// Returned when unit count is zero.
113    #[error("unit count must be at least 1")]
114    ZeroUnitCount,
115
116    /// Returned when fabric name is empty.
117    #[error("fabric name must not be empty")]
118    EmptyFabricName,
119
120    /// Returned when adapter version is empty.
121    #[error("adapter version must not be empty")]
122    EmptyAdapterVersion,
123
124    /// Returned when created_at timestamp is empty.
125    #[error("created_at timestamp must not be empty")]
126    EmptyCreatedAt,
127
128    /// Returned when fabric_name contains uppercase characters.
129    #[error("fabric name must be lowercase, got {value:?}")]
130    NonLowercaseFabricName {
131        /// The invalid fabric name.
132        value: String,
133    },
134
135    /// Returned when an unsupported CRS string is provided.
136    #[error("unsupported CRS: {value:?}, expected \"EPSG:4326\"")]
137    UnsupportedCrs {
138        /// The unrecognized CRS string.
139        value: String,
140    },
141
142    /// Returned when an unsupported format version is provided.
143    #[error("unsupported format version: {value:?}, expected \"0.2.1\"")]
144    UnsupportedFormatVersion {
145        /// The unrecognized version string.
146        value: String,
147    },
148
149    /// Returned when an unsupported topology string is provided.
150    #[error("unsupported topology: {value:?}, expected \"tree\" or \"dag\"")]
151    UnsupportedTopology {
152        /// The unrecognized topology string.
153        value: String,
154    },
155}
156
157/// Non-zero count of drainage units in a dataset.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
159pub struct UnitCount(u64);
160
161impl UnitCount {
162    /// Constructs a `UnitCount` from a raw `u64`, rejecting zero.
163    ///
164    /// # Errors
165    ///
166    /// | Variant | Condition |
167    /// |---|---|
168    /// | [`ManifestError::ZeroUnitCount`] | `raw` is 0 |
169    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    /// Returns the raw unit count value.
177    pub fn get(self) -> u64 {
178        self.0
179    }
180}
181
182/// Parsed and validated HFX dataset manifest.
183///
184/// Constructed exclusively via [`ManifestBuilder`]. All required invariants
185/// are enforced at build time.
186#[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    /// Returns the HFX format version declared in this manifest.
204    pub fn format_version(&self) -> FormatVersion {
205        self.format_version
206    }
207
208    /// Returns the source fabric name (e.g. `"example-fabric"`).
209    pub fn fabric_name(&self) -> &str {
210        &self.fabric_name
211    }
212
213    /// Returns the optional fabric version string, if declared.
214    pub fn fabric_version(&self) -> Option<&str> {
215        self.fabric_version.as_deref()
216    }
217
218    /// Returns the coordinate reference system for this dataset.
219    pub fn crs(&self) -> Crs {
220        self.crs
221    }
222
223    /// Returns whether upstream area values are precomputed in this dataset.
224    pub fn up_area(&self) -> UpAreaAvailability {
225        self.up_area
226    }
227
228    /// Returns the declared graph topology of this dataset.
229    pub fn topology(&self) -> Topology {
230        self.topology
231    }
232
233    /// Returns the optional region label for this dataset, if declared.
234    pub fn region(&self) -> Option<&str> {
235        self.region.as_deref()
236    }
237
238    /// Returns a reference to the dataset's spatial bounding box.
239    pub fn bbox(&self) -> &BoundingBox {
240        &self.bbox
241    }
242
243    /// Returns the non-zero count of drainage units in this dataset.
244    pub fn unit_count(&self) -> UnitCount {
245        self.unit_count
246    }
247
248    /// Returns the ISO 8601 creation timestamp string.
249    ///
250    /// Full timestamp parsing and validation is a validator concern; this
251    /// field is stored as-is from the manifest.
252    pub fn created_at(&self) -> &str {
253        &self.created_at
254    }
255
256    /// Returns the adapter version string that compiled this dataset.
257    pub fn adapter_version(&self) -> &str {
258        &self.adapter_version
259    }
260
261    /// Returns manifest-declared auxiliary artifact blocks.
262    pub fn auxiliary(&self) -> &[AuxiliaryDecl] {
263        &self.auxiliary
264    }
265}
266
267/// Builder for [`Manifest`].
268///
269/// Required fields are supplied to [`ManifestBuilder::new`] and validated
270/// immediately. Optional fields are set via chainable `with_*` methods.
271/// Call [`ManifestBuilder::build`] to produce the final [`Manifest`].
272#[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    /// Creates a new builder, validating all required fields immediately.
290    ///
291    /// # Errors
292    ///
293    /// | Variant | Condition |
294    /// |---|---|
295    /// | [`ManifestError::EmptyFabricName`] | `fabric_name` is empty |
296    /// | [`ManifestError::NonLowercaseFabricName`] | `fabric_name` contains uppercase characters |
297    /// | [`ManifestError::EmptyAdapterVersion`] | `adapter_version` is empty |
298    /// | [`ManifestError::EmptyCreatedAt`] | `created_at` is empty |
299    #[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    /// Declares that `up_area_km2` is precomputed for applicable units.
344    pub fn with_up_area(mut self) -> Self {
345        self.up_area = UpAreaAvailability::Precomputed;
346        self
347    }
348
349    /// Sets the optional fabric version string.
350    pub fn with_fabric_version(mut self, v: impl Into<String>) -> Self {
351        self.fabric_version = Some(v.into());
352        self
353    }
354
355    /// Sets the optional region label for this dataset.
356    pub fn with_region(mut self, region: impl Into<String>) -> Self {
357        self.region = Some(region.into());
358        self
359    }
360
361    /// Appends a manifest-declared auxiliary artifact block.
362    pub fn with_auxiliary(mut self, auxiliary: AuxiliaryDecl) -> Self {
363        self.auxiliary.push(auxiliary);
364        self
365    }
366
367    /// Consumes the builder and returns a validated [`Manifest`].
368    ///
369    /// This method is infallible: all validation occurs in [`ManifestBuilder::new`].
370    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    // --- UnitCount ---
418
419    #[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    // --- ManifestBuilder validation ---
438
439    #[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    // --- Minimal manifest defaults ---
523
524    #[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    // --- Optional field builders ---
549
550    #[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    // --- Display / FromStr roundtrips ---
583
584    #[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    // --- FromStr error cases ---
610
611    #[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}