stygian_charon/vendor_resolver/
error.rs1use thiserror::Error;
28
29#[derive(Debug, Error)]
31pub enum VendorResolverError {
32 #[error("resolution rule '{rule_id}': field '{field}' has invalid value '{value}': {reason}")]
34 InvalidField {
35 rule_id: String,
37 field: String,
39 value: String,
41 reason: String,
43 },
44
45 #[error("resolution rule '{rule_id}': missing required field '{field}'")]
47 MissingField {
48 rule_id: String,
50 field: String,
52 },
53
54 #[error("duplicate resolution rule id '{rule_id}' in input bundle")]
56 DuplicateId {
57 rule_id: String,
59 },
60
61 #[error("resolution rule '{rule_id}' references unknown vendor '{vendor_id}'")]
64 UnknownVendor {
65 rule_id: String,
67 vendor_id: String,
69 },
70
71 #[error("resolution rule TOML parse error: {0}")]
73 TomlParse(#[from] toml::de::Error),
74}
75
76impl VendorResolverError {
77 #[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 #[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 #[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 #[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}