Skip to main content

stygian_charon/vendor_classifier/
error.rs

1//! Error type for vendor classifier loading and validation (T89).
2//!
3//! Mirrors the [`crate::playbooks::ValidationError`] shape so
4//! operators can read either error class with the same mental model:
5//! every variant reports the offending **vendor id**, the **field
6//! path**, and the **bad value** (where applicable).
7
8use thiserror::Error;
9
10/// Errors returned by vendor-definition validation and loading.
11///
12/// Every variant embeds the **vendor id** and, where applicable, the
13/// **field path** (a dotted path such as `signals[2].weight`) plus
14/// the **bad value** as a string. The Display impl formats all three
15/// so the operator-facing message is actionable without any
16/// auxiliary lookup.
17#[derive(Debug, Error)]
18pub enum VendorError {
19    /// A field failed semantic validation (empty pattern, out-of-range
20    /// weight, unknown source, etc.).
21    #[error("vendor '{vendor_id}': field '{field}' has invalid value '{value}': {reason}")]
22    InvalidField {
23        /// Vendor containing the offending field.
24        vendor_id: String,
25        /// Field path (dotted JSON-pointer-style).
26        field: String,
27        /// String form of the bad value.
28        value: String,
29        /// Human-readable reason the value was rejected.
30        reason: String,
31    },
32
33    /// A required field is missing from the TOML payload.
34    #[error("vendor '{vendor_id}': missing required field '{field}'")]
35    MissingField {
36        /// Vendor missing the field.
37        vendor_id: String,
38        /// Field path (dotted JSON-pointer-style).
39        field: String,
40    },
41
42    /// The same vendor id appears more than once in the input bundle.
43    #[error("duplicate vendor id '{vendor_id}' in input bundle")]
44    DuplicateId {
45        /// Conflicting vendor id.
46        vendor_id: String,
47    },
48
49    /// The vendor id from the TOML does not match the
50    /// [`crate::vendor_classifier::VendorId`]
51    /// taxonomy.
52    #[error("vendor '{vendor_id}' is not part of the supported taxonomy")]
53    UnknownVendorId {
54        /// Vendor id the loader did not recognise.
55        vendor_id: String,
56    },
57
58    /// The TOML parser reported a structural error.
59    #[error("vendor TOML parse error: {0}")]
60    TomlParse(#[from] toml::de::Error),
61}
62
63impl VendorError {
64    /// Convenience constructor for [`VendorError::InvalidField`].
65    #[must_use]
66    pub fn invalid_field(
67        vendor_id: impl Into<String>,
68        field: impl Into<String>,
69        value: impl std::fmt::Display,
70        reason: impl Into<String>,
71    ) -> Self {
72        Self::InvalidField {
73            vendor_id: vendor_id.into(),
74            field: field.into(),
75            value: value.to_string(),
76            reason: reason.into(),
77        }
78    }
79
80    /// Convenience constructor for [`VendorError::MissingField`].
81    #[must_use]
82    pub fn missing_field(vendor_id: impl Into<String>, field: impl Into<String>) -> Self {
83        Self::MissingField {
84            vendor_id: vendor_id.into(),
85            field: field.into(),
86        }
87    }
88
89    /// Field path (dotted JSON-pointer-style) when applicable.
90    #[must_use]
91    pub fn field_path(&self) -> Option<&str> {
92        match self {
93            Self::InvalidField { field, .. } | Self::MissingField { field, .. } => Some(field),
94            _ => None,
95        }
96    }
97
98    /// Bad value (string form) when applicable.
99    #[must_use]
100    pub fn bad_value(&self) -> Option<&str> {
101        match self {
102            Self::InvalidField { value, .. } => Some(value),
103            _ => None,
104        }
105    }
106}
107
108#[cfg(test)]
109#[allow(
110    clippy::unwrap_used,
111    clippy::expect_used,
112    clippy::panic,
113    clippy::indexing_slicing
114)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn invalid_field_message_includes_field_and_value() {
120        let err =
121            VendorError::invalid_field("datadome", "signals[0].weight", "-1", "weight must be > 0");
122        let msg = err.to_string();
123        assert!(msg.contains("datadome"));
124        assert!(msg.contains("signals[0].weight"));
125        assert!(msg.contains("-1"));
126        assert!(msg.contains("weight must be > 0"));
127        assert_eq!(err.field_path(), Some("signals[0].weight"));
128        assert_eq!(err.bad_value(), Some("-1"));
129    }
130
131    #[test]
132    fn missing_field_message_includes_field() {
133        let err = VendorError::missing_field("cloudflare", "display_name");
134        let msg = err.to_string();
135        assert!(msg.contains("cloudflare"));
136        assert!(msg.contains("display_name"));
137        assert_eq!(err.field_path(), Some("display_name"));
138        assert_eq!(err.bad_value(), None);
139    }
140
141    #[test]
142    fn duplicate_id_does_not_report_field() {
143        let err = VendorError::DuplicateId {
144            vendor_id: "akamai".to_string(),
145        };
146        assert_eq!(err.field_path(), None);
147        assert_eq!(err.bad_value(), None);
148        assert!(err.to_string().contains("akamai"));
149    }
150}