Skip to main content

stygian_charon/vendor_resolver/
error.rs

1//! Errors returned by the vendor-to-playbook resolver (T90).
2//!
3//! Every variant embeds the **rule id** and, where applicable, the
4//! **field path** plus the **bad value** as a string. The format
5//! mirrors the existing [`crate::playbooks::ValidationError`] and
6//! [`crate::vendor_classifier::VendorError`] shapes so operators
7//! can read any of the three error classes with the same mental
8//! model.
9//!
10//! # Example
11//!
12//! ```
13//! use stygian_charon::vendor_resolver::VendorResolverError;
14//!
15//! let err = VendorResolverError::invalid_rule(
16//!     "tier2-hostile",
17//!     "min_confidence",
18//!     "2.0",
19//!     "min_confidence must be in [0.0, 1.0]",
20//! );
21//! let msg = err.to_string();
22//! assert!(msg.contains("tier2-hostile"));
23//! assert!(msg.contains("min_confidence"));
24//! assert!(msg.contains("2.0"));
25//! ```
26
27use thiserror::Error;
28
29/// Errors returned by vendor-resolver rule validation and loading.
30#[derive(Debug, Error)]
31pub enum VendorResolverError {
32    /// A field on a resolution rule failed semantic validation.
33    #[error("resolution rule '{rule_id}': field '{field}' has invalid value '{value}': {reason}")]
34    InvalidField {
35        /// Rule id containing the offending field.
36        rule_id: String,
37        /// Field path (dotted JSON-pointer-style).
38        field: String,
39        /// String form of the bad value.
40        value: String,
41        /// Human-readable reason the value was rejected.
42        reason: String,
43    },
44
45    /// A required field is missing from the TOML payload.
46    #[error("resolution rule '{rule_id}': missing required field '{field}'")]
47    MissingField {
48        /// Rule id missing the field.
49        rule_id: String,
50        /// Field path (dotted JSON-pointer-style).
51        field: String,
52    },
53
54    /// The same rule id appears more than once in the input bundle.
55    #[error("duplicate resolution rule id '{rule_id}' in input bundle")]
56    DuplicateId {
57        /// Conflicting rule id.
58        rule_id: String,
59    },
60
61    /// A `[[vendors]]` entry referenced an unknown
62    /// [`crate::vendor_classifier::VendorId`].
63    #[error("resolution rule '{rule_id}' references unknown vendor '{vendor_id}'")]
64    UnknownVendor {
65        /// Rule id that referenced the unknown vendor.
66        rule_id: String,
67        /// Vendor label that did not parse.
68        vendor_id: String,
69    },
70
71    /// The TOML parser reported a structural error.
72    #[error("resolution rule TOML parse error: {0}")]
73    TomlParse(#[from] toml::de::Error),
74}
75
76impl VendorResolverError {
77    /// Convenience constructor for [`VendorResolverError::InvalidField`].
78    #[must_use]
79    pub fn invalid_rule(
80        rule_id: impl Into<String>,
81        field: impl Into<String>,
82        value: impl std::fmt::Display,
83        reason: impl Into<String>,
84    ) -> Self {
85        Self::InvalidField {
86            rule_id: rule_id.into(),
87            field: field.into(),
88            value: value.to_string(),
89            reason: reason.into(),
90        }
91    }
92
93    /// Convenience constructor for [`VendorResolverError::MissingField`].
94    #[must_use]
95    pub fn missing_field(rule_id: impl Into<String>, field: impl Into<String>) -> Self {
96        Self::MissingField {
97            rule_id: rule_id.into(),
98            field: field.into(),
99        }
100    }
101
102    /// Field path (dotted JSON-pointer-style) when applicable.
103    #[must_use]
104    pub fn field_path(&self) -> Option<&str> {
105        match self {
106            Self::InvalidField { field, .. } | Self::MissingField { field, .. } => Some(field),
107            _ => None,
108        }
109    }
110
111    /// Bad value (string form) when applicable.
112    #[must_use]
113    pub fn bad_value(&self) -> Option<&str> {
114        match self {
115            Self::InvalidField { value, .. } => Some(value),
116            _ => None,
117        }
118    }
119}
120
121#[cfg(test)]
122#[allow(
123    clippy::unwrap_used,
124    clippy::expect_used,
125    clippy::panic,
126    clippy::indexing_slicing
127)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn invalid_rule_message_includes_rule_field_and_value() {
133        let err = VendorResolverError::invalid_rule(
134            "tier2-hostile",
135            "min_confidence",
136            "2.0",
137            "must be in [0.0, 1.0]",
138        );
139        let msg = err.to_string();
140        assert!(msg.contains("tier2-hostile"));
141        assert!(msg.contains("min_confidence"));
142        assert!(msg.contains("2.0"));
143        assert!(msg.contains("must be in [0.0, 1.0]"));
144        assert_eq!(err.field_path(), Some("min_confidence"));
145        assert_eq!(err.bad_value(), Some("2.0"));
146    }
147
148    #[test]
149    fn missing_field_message_includes_field() {
150        let err = VendorResolverError::missing_field("tier1-js", "playbook_id");
151        let msg = err.to_string();
152        assert!(msg.contains("tier1-js"));
153        assert!(msg.contains("playbook_id"));
154        assert_eq!(err.field_path(), Some("playbook_id"));
155        assert_eq!(err.bad_value(), None);
156    }
157
158    #[test]
159    fn duplicate_id_does_not_report_field() {
160        let err = VendorResolverError::DuplicateId {
161            rule_id: "tier2-hostile".to_string(),
162        };
163        assert_eq!(err.field_path(), None);
164        assert_eq!(err.bad_value(), None);
165        assert!(err.to_string().contains("tier2-hostile"));
166    }
167
168    #[test]
169    fn unknown_vendor_message_includes_label() {
170        let err = VendorResolverError::UnknownVendor {
171            rule_id: "tier2-hostile".to_string(),
172            vendor_id: "nope".to_string(),
173        };
174        let msg = err.to_string();
175        assert!(msg.contains("tier2-hostile"));
176        assert!(msg.contains("nope"));
177    }
178}