Skip to main content

hfx_core/
auxiliary.rs

1//! Manifest-declared auxiliary artifact types.
2
3use std::collections::BTreeMap;
4use std::str::FromStr;
5
6/// Errors from parsing auxiliary declarations.
7#[derive(Debug, thiserror::Error)]
8pub enum AuxiliaryError {
9    /// Returned when an auxiliary schema ID is empty.
10    #[error("auxiliary schema id must not be empty")]
11    EmptySchemaId,
12
13    /// Returned when an HFX-owned schema ID is malformed or unsupported.
14    #[error("malformed auxiliary schema id: {value:?}")]
15    MalformedSchemaId {
16        /// The invalid schema ID.
17        value: String,
18    },
19
20    /// Returned when an artifact key is empty.
21    #[error("auxiliary artifact key must not be empty")]
22    EmptyArtifactKey,
23
24    /// Returned when an artifact path is empty.
25    #[error("auxiliary artifact path must not be empty for key {key:?}")]
26    EmptyArtifactPath {
27        /// The artifact key whose path is empty.
28        key: String,
29    },
30}
31
32/// Blessed auxiliary schemas with stable HFX validator support.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum BlessedAuxSchema {
35    /// Paired D8 flow-direction and flow-accumulation rasters.
36    D8RasterV1,
37    /// Optional snap features for outlet snapping.
38    SnapV1,
39}
40
41impl std::fmt::Display for BlessedAuxSchema {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            BlessedAuxSchema::D8RasterV1 => write!(f, "hfx.aux.d8_raster.v1"),
45            BlessedAuxSchema::SnapV1 => write!(f, "hfx.aux.snap.v1"),
46        }
47    }
48}
49
50/// Parsed auxiliary schema ID.
51#[derive(Debug, Clone, PartialEq, Eq, Hash)]
52pub enum AuxiliarySchemaId {
53    /// Stable HFX-owned schema.
54    Blessed(BlessedAuxSchema),
55    /// Provisional HFX-owned schema.
56    Provisional(String),
57    /// Third-party schema, expected to use reverse-DNS naming.
58    ThirdParty(String),
59}
60
61impl AuxiliarySchemaId {
62    /// Parse and classify an auxiliary schema ID.
63    ///
64    /// # Errors
65    ///
66    /// | Condition | Error variant |
67    /// |---|---|
68    /// | `raw` is empty | [`AuxiliaryError::EmptySchemaId`] |
69    /// | unsupported `hfx.aux.*` schema | [`AuxiliaryError::MalformedSchemaId`] |
70    pub fn parse(raw: &str) -> Result<Self, AuxiliaryError> {
71        if raw.is_empty() {
72            return Err(AuxiliaryError::EmptySchemaId);
73        }
74        match raw {
75            "hfx.aux.d8_raster.v1" => Ok(Self::Blessed(BlessedAuxSchema::D8RasterV1)),
76            "hfx.aux.snap.v1" => Ok(Self::Blessed(BlessedAuxSchema::SnapV1)),
77            value if value.starts_with("hfx.aux.") => Err(AuxiliaryError::MalformedSchemaId {
78                value: value.to_owned(),
79            }),
80            value if value.starts_with("hfx.x.") => Ok(Self::Provisional(value.to_owned())),
81            value => Ok(Self::ThirdParty(value.to_owned())),
82        }
83    }
84}
85
86impl std::fmt::Display for AuxiliarySchemaId {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            AuxiliarySchemaId::Blessed(schema) => write!(f, "{schema}"),
90            AuxiliarySchemaId::Provisional(value) | AuxiliarySchemaId::ThirdParty(value) => {
91                write!(f, "{value}")
92            }
93        }
94    }
95}
96
97impl FromStr for AuxiliarySchemaId {
98    type Err = AuxiliaryError;
99
100    fn from_str(s: &str) -> Result<Self, Self::Err> {
101        Self::parse(s)
102    }
103}
104
105/// A manifest-declared auxiliary artifact block.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct AuxiliaryDecl {
108    schema: AuxiliarySchemaId,
109    artifacts: BTreeMap<String, String>,
110}
111
112impl AuxiliaryDecl {
113    /// Construct an [`AuxiliaryDecl`] from a schema and artifact map.
114    ///
115    /// Metadata is intentionally not modeled in `hfx-core`; schema-specific
116    /// metadata parsing belongs at the manifest deserialization boundary.
117    ///
118    /// # Errors
119    ///
120    /// | Condition | Error variant |
121    /// |---|---|
122    /// | any artifact key is empty | [`AuxiliaryError::EmptyArtifactKey`] |
123    /// | any artifact path is empty | [`AuxiliaryError::EmptyArtifactPath`] |
124    pub fn new(
125        schema: AuxiliarySchemaId,
126        artifacts: BTreeMap<String, String>,
127    ) -> Result<Self, AuxiliaryError> {
128        for (key, path) in &artifacts {
129            if key.is_empty() {
130                return Err(AuxiliaryError::EmptyArtifactKey);
131            }
132            if path.is_empty() {
133                return Err(AuxiliaryError::EmptyArtifactPath { key: key.clone() });
134            }
135        }
136        Ok(Self { schema, artifacts })
137    }
138
139    /// Return the parsed auxiliary schema ID.
140    pub fn schema(&self) -> &AuxiliarySchemaId {
141        &self.schema
142    }
143
144    /// Return the artifact key-to-path map.
145    pub fn artifacts(&self) -> &BTreeMap<String, String> {
146        &self.artifacts
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn parse_blessed_d8_raster() {
156        assert_eq!(
157            AuxiliarySchemaId::parse("hfx.aux.d8_raster.v1").unwrap(),
158            AuxiliarySchemaId::Blessed(BlessedAuxSchema::D8RasterV1)
159        );
160    }
161
162    #[test]
163    fn parse_blessed_snap_v1() {
164        assert_eq!(
165            AuxiliarySchemaId::parse("hfx.aux.snap.v1").unwrap(),
166            AuxiliarySchemaId::Blessed(BlessedAuxSchema::SnapV1)
167        );
168    }
169
170    #[test]
171    fn display_blessed_snap_v1() {
172        assert_eq!(BlessedAuxSchema::SnapV1.to_string(), "hfx.aux.snap.v1");
173    }
174
175    #[test]
176    fn parse_provisional_schema() {
177        assert_eq!(
178            AuxiliarySchemaId::parse("hfx.x.experimental.v1").unwrap(),
179            AuxiliarySchemaId::Provisional("hfx.x.experimental.v1".to_string())
180        );
181    }
182
183    #[test]
184    fn parse_third_party_schema() {
185        assert_eq!(
186            AuxiliarySchemaId::parse("org.example.custom.v1").unwrap(),
187            AuxiliarySchemaId::ThirdParty("org.example.custom.v1".to_string())
188        );
189    }
190
191    #[test]
192    fn parse_empty_schema_fails() {
193        assert!(matches!(
194            AuxiliarySchemaId::parse(""),
195            Err(AuxiliaryError::EmptySchemaId)
196        ));
197    }
198
199    #[test]
200    fn parse_unknown_blessed_schema_fails() {
201        assert!(matches!(
202            AuxiliarySchemaId::parse("hfx.aux.other.v1"),
203            Err(AuxiliaryError::MalformedSchemaId { .. })
204        ));
205    }
206
207    #[test]
208    fn auxiliary_decl_rejects_empty_path() {
209        let mut artifacts = BTreeMap::new();
210        artifacts.insert("flow_dir".to_string(), String::new());
211        assert!(matches!(
212            AuxiliaryDecl::new(
213                AuxiliarySchemaId::Blessed(BlessedAuxSchema::D8RasterV1),
214                artifacts
215            ),
216            Err(AuxiliaryError::EmptyArtifactPath { key }) if key == "flow_dir"
217        ));
218    }
219}