use std::str::FromStr;
use crate::auxiliary::AuxiliaryDecl;
use crate::geo::BoundingBox;
#[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,
V0_2_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"),
FormatVersion::V0_2_1 => write!(f, "0.2.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),
"0.2.1" => Ok(FormatVersion::V0_2_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, thiserror::Error)]
pub enum ManifestError {
#[error("unit count must be at least 1")]
ZeroUnitCount,
#[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("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.2.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 UnitCount(u64);
impl UnitCount {
pub fn new(raw: u64) -> Result<Self, ManifestError> {
if raw == 0 {
return Err(ManifestError::ZeroUnitCount);
}
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>,
crs: Crs,
up_area: UpAreaAvailability,
topology: Topology,
region: Option<String>,
bbox: BoundingBox,
unit_count: UnitCount,
created_at: String,
adapter_version: String,
auxiliary: Vec<AuxiliaryDecl>,
}
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 crs(&self) -> Crs {
self.crs
}
pub fn up_area(&self) -> UpAreaAvailability {
self.up_area
}
pub fn topology(&self) -> Topology {
self.topology
}
pub fn region(&self) -> Option<&str> {
self.region.as_deref()
}
pub fn bbox(&self) -> &BoundingBox {
&self.bbox
}
pub fn unit_count(&self) -> UnitCount {
self.unit_count
}
pub fn created_at(&self) -> &str {
&self.created_at
}
pub fn adapter_version(&self) -> &str {
&self.adapter_version
}
pub fn auxiliary(&self) -> &[AuxiliaryDecl] {
&self.auxiliary
}
}
#[derive(Debug)]
pub struct ManifestBuilder {
format_version: FormatVersion,
fabric_name: String,
crs: Crs,
topology: Topology,
bbox: BoundingBox,
unit_count: UnitCount,
created_at: String,
adapter_version: String,
up_area: UpAreaAvailability,
fabric_version: Option<String>,
region: Option<String>,
auxiliary: Vec<AuxiliaryDecl>,
}
impl ManifestBuilder {
#[allow(clippy::too_many_arguments)]
pub fn new(
format_version: FormatVersion,
fabric_name: impl Into<String>,
crs: Crs,
topology: Topology,
bbox: BoundingBox,
unit_count: UnitCount,
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 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,
bbox,
unit_count,
created_at,
adapter_version,
up_area: UpAreaAvailability::NotAvailable,
fabric_version: None,
region: None,
auxiliary: Vec::new(),
})
}
pub fn with_up_area(mut self) -> Self {
self.up_area = UpAreaAvailability::Precomputed;
self
}
pub fn with_fabric_version(mut self, v: impl Into<String>) -> Self {
self.fabric_version = Some(v.into());
self
}
pub fn with_region(mut self, region: impl Into<String>) -> Self {
self.region = Some(region.into());
self
}
pub fn with_auxiliary(mut self, auxiliary: AuxiliaryDecl) -> Self {
self.auxiliary.push(auxiliary);
self
}
pub fn build(self) -> Manifest {
Manifest {
format_version: self.format_version,
fabric_name: self.fabric_name,
fabric_version: self.fabric_version,
crs: self.crs,
up_area: self.up_area,
topology: self.topology,
region: self.region,
bbox: self.bbox,
unit_count: self.unit_count,
created_at: self.created_at,
adapter_version: self.adapter_version,
auxiliary: self.auxiliary,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auxiliary::{AuxiliarySchemaId, BlessedAuxSchema};
use crate::geo::BoundingBox;
use std::collections::BTreeMap;
fn test_bbox() -> BoundingBox {
BoundingBox::new(-10.0, -5.0, 10.0, 5.0).unwrap()
}
fn test_unit_count(n: u64) -> UnitCount {
UnitCount::new(n).unwrap()
}
fn minimal_builder() -> ManifestBuilder {
ManifestBuilder::new(
FormatVersion::V0_2_1,
"testfabric",
Crs::Epsg4326,
Topology::Tree,
test_bbox(),
test_unit_count(100),
"2026-01-01T00:00:00Z",
"hfx-adapter-v1",
)
.unwrap()
}
#[test]
fn unit_count_new_one_succeeds() {
let count = UnitCount::new(1).unwrap();
assert_eq!(count.get(), 1);
}
#[test]
fn unit_count_new_zero_fails_with_zero_unit_count() {
let err = UnitCount::new(0).unwrap_err();
assert!(matches!(err, ManifestError::ZeroUnitCount));
}
#[test]
fn unit_count_new_u64_max_succeeds() {
let count = UnitCount::new(u64::MAX).unwrap();
assert_eq!(count.get(), u64::MAX);
}
#[test]
fn builder_empty_fabric_name_fails() {
let err = ManifestBuilder::new(
FormatVersion::V0_2_1,
"",
Crs::Epsg4326,
Topology::Tree,
test_bbox(),
test_unit_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_2_1,
"testfabric",
Crs::Epsg4326,
Topology::Tree,
test_bbox(),
test_unit_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_2_1,
"testfabric",
Crs::Epsg4326,
Topology::Tree,
test_bbox(),
test_unit_count(1),
"",
"v1",
)
.err()
.unwrap();
assert!(matches!(err, ManifestError::EmptyCreatedAt));
}
#[test]
fn fabric_name_uppercase_fails() {
let err = ManifestBuilder::new(
FormatVersion::V0_2_1,
"HydroBASINS",
Crs::Epsg4326,
Topology::Tree,
test_bbox(),
test_unit_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_2_1,
"testfabric",
Crs::Epsg4326,
Topology::Tree,
test_bbox(),
test_unit_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.format_version(), FormatVersion::V0_2_1);
assert_eq!(manifest.crs(), Crs::Epsg4326);
assert_eq!(manifest.fabric_version(), None);
assert_eq!(manifest.region(), None);
assert_eq!(manifest.auxiliary(), &[]);
}
#[test]
fn crs_getter_returns_enum() {
let manifest = minimal_builder().build();
assert_eq!(manifest.crs(), Crs::Epsg4326);
}
#[test]
fn unit_count_getter_returns_unit_count() {
let manifest = minimal_builder().build();
assert_eq!(manifest.unit_count(), test_unit_count(100));
}
#[test]
fn with_up_area_sets_precomputed() {
let manifest = minimal_builder().with_up_area().build();
assert_eq!(manifest.up_area(), UpAreaAvailability::Precomputed);
}
#[test]
fn all_optional_fields_set_come_through() {
let mut artifacts = BTreeMap::new();
artifacts.insert("flow_dir".to_string(), "flow_dir.tif".to_string());
artifacts.insert("flow_acc".to_string(), "flow_acc.tif".to_string());
let auxiliary = AuxiliaryDecl::new(
AuxiliarySchemaId::Blessed(BlessedAuxSchema::D8RasterV1),
artifacts,
)
.unwrap();
let manifest = minimal_builder()
.with_up_area()
.with_fabric_version("v2024")
.with_region("North America")
.with_auxiliary(auxiliary.clone())
.build();
assert_eq!(manifest.up_area(), UpAreaAvailability::Precomputed);
assert_eq!(manifest.fabric_version(), Some("v2024"));
assert_eq!(manifest.region(), Some("North America"));
assert_eq!(manifest.auxiliary(), &[auxiliary]);
assert_eq!(manifest.format_version(), FormatVersion::V0_2_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);
assert_eq!(FormatVersion::V0_2_1.to_string(), "0.2.1");
assert_eq!(
"0.2.1".parse::<FormatVersion>().unwrap(),
FormatVersion::V0_2_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 = "1.0".parse::<FormatVersion>().unwrap_err();
assert!(matches!(
err,
ManifestError::UnsupportedFormatVersion { .. }
));
}
}