use std::str::FromStr;
use crate::geo::BoundingBox;
use crate::raster::FlowDirEncoding;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Topology {
Tree,
Dag,
}
impl std::fmt::Display for Topology {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Topology::Tree => write!(f, "tree"),
Topology::Dag => write!(f, "dag"),
}
}
}
impl FromStr for Topology {
type Err = ManifestError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"tree" => Ok(Topology::Tree),
"dag" => Ok(Topology::Dag),
_ => Err(ManifestError::UnsupportedTopology { value: s.to_owned() }),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FormatVersion {
V0_1,
}
impl std::fmt::Display for FormatVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FormatVersion::V0_1 => write!(f, "0.1"),
}
}
}
impl FromStr for FormatVersion {
type Err = ManifestError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"0.1" => Ok(FormatVersion::V0_1),
_ => Err(ManifestError::UnsupportedFormatVersion { value: s.to_owned() }),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Crs {
Epsg4326,
}
impl std::fmt::Display for Crs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Crs::Epsg4326 => write!(f, "EPSG:4326"),
}
}
}
impl FromStr for Crs {
type Err = ManifestError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"EPSG:4326" => Ok(Crs::Epsg4326),
_ => Err(ManifestError::UnsupportedCrs { value: s.to_owned() }),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum UpAreaAvailability {
Precomputed,
NotAvailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum RasterAvailability {
Present(FlowDirEncoding),
Absent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SnapAvailability {
Present,
Absent,
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
#[error("atom count must be at least 1")]
ZeroAtomCount,
#[error("fabric name must not be empty")]
EmptyFabricName,
#[error("adapter version must not be empty")]
EmptyAdapterVersion,
#[error("created_at timestamp must not be empty")]
EmptyCreatedAt,
#[error("terminal_sink_id must be 0, got {value}")]
InvalidTerminalSinkId {
value: i64,
},
#[error("fabric name must be lowercase, got {value:?}")]
NonLowercaseFabricName {
value: String,
},
#[error("unsupported CRS: {value:?}, expected \"EPSG:4326\"")]
UnsupportedCrs {
value: String,
},
#[error("unsupported format version: {value:?}, expected \"0.1\"")]
UnsupportedFormatVersion {
value: String,
},
#[error("unsupported topology: {value:?}, expected \"tree\" or \"dag\"")]
UnsupportedTopology {
value: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AtomCount(u64);
impl AtomCount {
pub fn new(raw: u64) -> Result<Self, ManifestError> {
if raw == 0 {
return Err(ManifestError::ZeroAtomCount);
}
Ok(Self(raw))
}
pub fn get(self) -> u64 {
self.0
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Manifest {
format_version: FormatVersion,
fabric_name: String,
fabric_version: Option<String>,
fabric_level: Option<u32>,
crs: Crs,
up_area: UpAreaAvailability,
rasters: RasterAvailability,
snap: SnapAvailability,
topology: Topology,
terminal_sink_id: i64,
region: Option<String>,
bbox: BoundingBox,
atom_count: AtomCount,
created_at: String,
adapter_version: String,
}
impl Manifest {
pub fn format_version(&self) -> FormatVersion {
self.format_version
}
pub fn fabric_name(&self) -> &str {
&self.fabric_name
}
pub fn fabric_version(&self) -> Option<&str> {
self.fabric_version.as_deref()
}
pub fn fabric_level(&self) -> Option<u32> {
self.fabric_level
}
pub fn crs(&self) -> Crs {
self.crs
}
pub fn up_area(&self) -> UpAreaAvailability {
self.up_area
}
pub fn rasters(&self) -> RasterAvailability {
self.rasters
}
pub fn snap(&self) -> SnapAvailability {
self.snap
}
pub fn topology(&self) -> Topology {
self.topology
}
pub fn terminal_sink_id(&self) -> i64 {
self.terminal_sink_id
}
pub fn region(&self) -> Option<&str> {
self.region.as_deref()
}
pub fn bbox(&self) -> &BoundingBox {
&self.bbox
}
pub fn atom_count(&self) -> AtomCount {
self.atom_count
}
pub fn created_at(&self) -> &str {
&self.created_at
}
pub fn adapter_version(&self) -> &str {
&self.adapter_version
}
}
#[derive(Debug)]
pub struct ManifestBuilder {
format_version: FormatVersion,
fabric_name: String,
crs: Crs,
topology: Topology,
terminal_sink_id: i64,
bbox: BoundingBox,
atom_count: AtomCount,
created_at: String,
adapter_version: String,
up_area: UpAreaAvailability,
rasters: RasterAvailability,
snap: SnapAvailability,
fabric_version: Option<String>,
fabric_level: Option<u32>,
region: Option<String>,
}
impl ManifestBuilder {
#[allow(clippy::too_many_arguments)]
pub fn new(
format_version: FormatVersion,
fabric_name: impl Into<String>,
crs: Crs,
topology: Topology,
terminal_sink_id: i64,
bbox: BoundingBox,
atom_count: AtomCount,
created_at: impl Into<String>,
adapter_version: impl Into<String>,
) -> Result<Self, ManifestError> {
let fabric_name = fabric_name.into();
let created_at = created_at.into();
let adapter_version = adapter_version.into();
if terminal_sink_id != 0 {
return Err(ManifestError::InvalidTerminalSinkId { value: terminal_sink_id });
}
if fabric_name.is_empty() {
return Err(ManifestError::EmptyFabricName);
}
if fabric_name.chars().any(|c| c.is_uppercase()) {
return Err(ManifestError::NonLowercaseFabricName { value: fabric_name });
}
if adapter_version.is_empty() {
return Err(ManifestError::EmptyAdapterVersion);
}
if created_at.is_empty() {
return Err(ManifestError::EmptyCreatedAt);
}
Ok(Self {
format_version,
fabric_name,
crs,
topology,
terminal_sink_id,
bbox,
atom_count,
created_at,
adapter_version,
up_area: UpAreaAvailability::NotAvailable,
rasters: RasterAvailability::Absent,
snap: SnapAvailability::Absent,
fabric_version: None,
fabric_level: None,
region: None,
})
}
pub fn with_up_area(mut self) -> Self {
self.up_area = UpAreaAvailability::Precomputed;
self
}
pub fn with_rasters(mut self, encoding: FlowDirEncoding) -> Self {
self.rasters = RasterAvailability::Present(encoding);
self
}
pub fn with_snap(mut self) -> Self {
self.snap = SnapAvailability::Present;
self
}
pub fn with_fabric_version(mut self, v: impl Into<String>) -> Self {
self.fabric_version = Some(v.into());
self
}
pub fn with_fabric_level(mut self, level: u32) -> Self {
self.fabric_level = Some(level);
self
}
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
pub fn build(self) -> Manifest {
Manifest {
format_version: self.format_version,
fabric_name: self.fabric_name,
fabric_version: self.fabric_version,
fabric_level: self.fabric_level,
crs: self.crs,
up_area: self.up_area,
rasters: self.rasters,
snap: self.snap,
topology: self.topology,
terminal_sink_id: self.terminal_sink_id,
region: self.region,
bbox: self.bbox,
atom_count: self.atom_count,
created_at: self.created_at,
adapter_version: self.adapter_version,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::geo::BoundingBox;
use crate::raster::FlowDirEncoding;
fn test_bbox() -> BoundingBox {
BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap()
}
fn test_atom_count(n: u64) -> AtomCount {
AtomCount::new(n).unwrap()
}
fn minimal_builder() -> ManifestBuilder {
ManifestBuilder::new(
FormatVersion::V0_1,
"testfabric",
Crs::Epsg4326,
Topology::Tree,
0,
test_bbox(),
test_atom_count(100),
"2026-01-01T00:00:00Z",
"hfx-adapter-v1",
)
.unwrap()
}
#[test]
fn atom_count_new_one_succeeds() {
let count = AtomCount::new(1).unwrap();
assert_eq!(count.get(), 1);
}
#[test]
fn atom_count_new_zero_fails_with_zero_atom_count() {
let err = AtomCount::new(0).unwrap_err();
assert!(matches!(err, ManifestError::ZeroAtomCount));
}
#[test]
fn atom_count_new_u64_max_succeeds() {
let count = AtomCount::new(u64::MAX).unwrap();
assert_eq!(count.get(), u64::MAX);
}
#[test]
fn builder_empty_fabric_name_fails() {
let err = ManifestBuilder::new(
FormatVersion::V0_1,
"",
Crs::Epsg4326,
Topology::Tree,
0,
test_bbox(),
test_atom_count(1),
"2026-01-01T00:00:00Z",
"v1",
)
.err()
.unwrap();
assert!(matches!(err, ManifestError::EmptyFabricName));
}
#[test]
fn builder_empty_adapter_version_fails() {
let err = ManifestBuilder::new(
FormatVersion::V0_1,
"testfabric",
Crs::Epsg4326,
Topology::Tree,
0,
test_bbox(),
test_atom_count(1),
"2026-01-01T00:00:00Z",
"",
)
.err()
.unwrap();
assert!(matches!(err, ManifestError::EmptyAdapterVersion));
}
#[test]
fn builder_empty_created_at_fails() {
let err = ManifestBuilder::new(
FormatVersion::V0_1,
"testfabric",
Crs::Epsg4326,
Topology::Tree,
0,
test_bbox(),
test_atom_count(1),
"",
"v1",
)
.err()
.unwrap();
assert!(matches!(err, ManifestError::EmptyCreatedAt));
}
#[test]
fn terminal_sink_id_nonzero_fails() {
let err = ManifestBuilder::new(
FormatVersion::V0_1,
"testfabric",
Crs::Epsg4326,
Topology::Tree,
5,
test_bbox(),
test_atom_count(1),
"2026-01-01T00:00:00Z",
"v1",
)
.err()
.unwrap();
assert!(matches!(err, ManifestError::InvalidTerminalSinkId { value: 5 }));
}
#[test]
fn fabric_name_uppercase_fails() {
let err = ManifestBuilder::new(
FormatVersion::V0_1,
"HydroBASINS",
Crs::Epsg4326,
Topology::Tree,
0,
test_bbox(),
test_atom_count(1),
"2026-01-01T00:00:00Z",
"v1",
)
.err()
.unwrap();
assert!(matches!(err, ManifestError::NonLowercaseFabricName { .. }));
}
#[test]
fn fabric_name_lowercase_succeeds() {
let result = ManifestBuilder::new(
FormatVersion::V0_1,
"testfabric",
Crs::Epsg4326,
Topology::Tree,
0,
test_bbox(),
test_atom_count(1),
"2026-01-01T00:00:00Z",
"v1",
);
assert!(result.is_ok());
}
#[test]
fn minimal_manifest_has_expected_defaults() {
let manifest = minimal_builder().build();
assert_eq!(manifest.up_area(), UpAreaAvailability::NotAvailable);
assert_eq!(manifest.rasters(), RasterAvailability::Absent);
assert_eq!(manifest.snap(), SnapAvailability::Absent);
assert_eq!(manifest.format_version(), FormatVersion::V0_1);
assert_eq!(manifest.crs(), Crs::Epsg4326);
assert_eq!(manifest.fabric_version(), None);
assert_eq!(manifest.fabric_level(), None);
assert_eq!(manifest.region(), None);
}
#[test]
fn crs_getter_returns_enum() {
let manifest = minimal_builder().build();
assert_eq!(manifest.crs(), Crs::Epsg4326);
}
#[test]
fn terminal_sink_id_getter_returns_zero() {
let manifest = minimal_builder().build();
assert_eq!(manifest.terminal_sink_id(), 0);
}
#[test]
fn with_up_area_sets_precomputed() {
let manifest = minimal_builder().with_up_area().build();
assert_eq!(manifest.up_area(), UpAreaAvailability::Precomputed);
}
#[test]
fn with_rasters_esri_sets_present_esri() {
let manifest = minimal_builder().with_rasters(FlowDirEncoding::Esri).build();
assert_eq!(manifest.rasters(), RasterAvailability::Present(FlowDirEncoding::Esri));
}
#[test]
fn with_snap_sets_present() {
let manifest = minimal_builder().with_snap().build();
assert_eq!(manifest.snap(), SnapAvailability::Present);
}
#[test]
fn all_optional_fields_set_come_through() {
let manifest = minimal_builder()
.with_up_area()
.with_rasters(FlowDirEncoding::Taudem)
.with_snap()
.with_fabric_version("v2024")
.with_fabric_level(8)
.with_region("North America")
.build();
assert_eq!(manifest.up_area(), UpAreaAvailability::Precomputed);
assert_eq!(manifest.rasters(), RasterAvailability::Present(FlowDirEncoding::Taudem));
assert_eq!(manifest.snap(), SnapAvailability::Present);
assert_eq!(manifest.fabric_version(), Some("v2024"));
assert_eq!(manifest.fabric_level(), Some(8));
assert_eq!(manifest.region(), Some("North America"));
assert_eq!(manifest.format_version(), FormatVersion::V0_1);
assert_eq!(manifest.crs(), Crs::Epsg4326);
}
#[test]
fn topology_display_roundtrip() {
assert_eq!(Topology::Tree.to_string(), "tree");
assert_eq!(Topology::Dag.to_string(), "dag");
assert_eq!("tree".parse::<Topology>().unwrap(), Topology::Tree);
assert_eq!("dag".parse::<Topology>().unwrap(), Topology::Dag);
}
#[test]
fn format_version_display_roundtrip() {
assert_eq!(FormatVersion::V0_1.to_string(), "0.1");
assert_eq!("0.1".parse::<FormatVersion>().unwrap(), FormatVersion::V0_1);
}
#[test]
fn crs_display_roundtrip() {
assert_eq!(Crs::Epsg4326.to_string(), "EPSG:4326");
assert_eq!("EPSG:4326".parse::<Crs>().unwrap(), Crs::Epsg4326);
}
#[test]
fn topology_fromstr_invalid() {
let err = "invalid".parse::<Topology>().unwrap_err();
assert!(matches!(err, ManifestError::UnsupportedTopology { .. }));
}
#[test]
fn crs_fromstr_invalid() {
let err = "EPSG:32632".parse::<Crs>().unwrap_err();
assert!(matches!(err, ManifestError::UnsupportedCrs { .. }));
}
#[test]
fn format_version_fromstr_invalid() {
let err = "0.2".parse::<FormatVersion>().unwrap_err();
assert!(matches!(err, ManifestError::UnsupportedFormatVersion { .. }));
}
}