stygian_charon/playbooks/error.rs
1//! Actionable validation errors for playbooks (T85).
2//!
3//! Every [`ValidationError`] variant **carries the field path and the
4//! bad value** so operators can locate the offending knob without
5//! re-running the loader. The format is stable:
6//!
7//! ```text
8//! playbook '<playbook_id>': field '<field>' has invalid value '<value>': <reason>
9//! ```
10//!
11//! # Example
12//!
13//! ```
14//! use stygian_charon::playbooks::{AcquisitionDefaults, EscalationStrategy, PacingProfile, Playbook, ProxyPreference};
15//! use stygian_charon::acquisition::AcquisitionModeHint;
16//! use stygian_charon::types::{ExecutionMode, SessionMode, TargetClass, TelemetryLevel};
17//!
18//! let bad = Playbook {
19//! id: "broken".to_string(),
20//! target_class: TargetClass::ContentSite,
21//! description: "intentionally broken".to_string(),
22//! acquisition: AcquisitionDefaults {
23//! retry_budget: 0,
24//! ..AcquisitionDefaults::default_for(TargetClass::ContentSite)
25//! },
26//! proxy_preference: ProxyPreference::default(),
27//! pacing: PacingProfile::default(),
28//! escalation: EscalationStrategy::Capped { ceiling: AcquisitionModeHint::Fast },
29//! };
30//! let err = bad.validate().expect_err("retry_budget must be > 0");
31//! let message = err.to_string();
32//! assert!(message.contains("acquisition.retry_budget"), "message must name the field: {message}");
33//! assert!(message.contains("0"), "message must include the bad value: {message}");
34//! ```
35
36use thiserror::Error;
37
38/// Errors returned by playbook validation and loading.
39///
40/// Every variant embeds the **playbook id**, the **field path** (a
41/// dotted JSON-pointer-style path such as `acquisition.retry_budget`),
42/// and the **bad value** as a string. The Display impl formats all
43/// three so the operator-facing message is actionable without any
44/// auxiliary lookup.
45#[derive(Debug, Error)]
46pub enum ValidationError {
47 /// A field failed semantic validation (out of range, wrong
48 /// multiplicity, inconsistent state).
49 #[error("playbook '{playbook_id}': field '{field}' has invalid value '{value}': {reason}")]
50 InvalidField {
51 /// Playbook containing the offending field.
52 playbook_id: String,
53 /// Field path (dotted JSON-pointer-style).
54 field: String,
55 /// String form of the bad value.
56 value: String,
57 /// Human-readable reason the value was rejected.
58 reason: String,
59 },
60
61 /// A required field is missing from the TOML payload.
62 #[error("playbook '{playbook_id}': missing required field '{field}'")]
63 MissingField {
64 /// Playbook missing the field.
65 playbook_id: String,
66 /// Field path (dotted JSON-pointer-style).
67 field: String,
68 },
69
70 /// The same playbook id appears more than once in the input
71 /// bundle.
72 #[error("duplicate playbook id '{playbook_id}' in input bundle")]
73 DuplicateId {
74 /// Conflicting playbook id.
75 playbook_id: String,
76 },
77
78 /// The TOML parser reported a structural error.
79 #[error("playbook TOML parse error: {0}")]
80 TomlParse(#[from] toml::de::Error),
81
82 /// The resolver was asked for a playbook id that is not loaded.
83 #[error("playbook '{playbook_id}' not registered in resolver")]
84 UnknownPlaybook {
85 /// Playbook id the resolver could not find.
86 playbook_id: String,
87 },
88
89 /// A reference to a sibling playbook id (e.g. an
90 /// `extends = "tier1-static"` declaration) could not be resolved.
91 #[error("playbook '{playbook_id}' extends unknown playbook '{parent_id}'")]
92 UnknownParent {
93 /// Child playbook id.
94 playbook_id: String,
95 /// Parent playbook id it tried to extend.
96 parent_id: String,
97 },
98}
99
100impl ValidationError {
101 /// Convenience constructor for [`ValidationError::InvalidField`]
102 /// that builds the field path and bad-value string from caller
103 /// inputs.
104 #[must_use]
105 pub fn invalid_field(
106 playbook_id: impl Into<String>,
107 field: impl Into<String>,
108 value: impl std::fmt::Display,
109 reason: impl Into<String>,
110 ) -> Self {
111 Self::InvalidField {
112 playbook_id: playbook_id.into(),
113 field: field.into(),
114 value: value.to_string(),
115 reason: reason.into(),
116 }
117 }
118
119 /// Convenience constructor for [`ValidationError::MissingField`].
120 #[must_use]
121 pub fn missing_field(playbook_id: impl Into<String>, field: impl Into<String>) -> Self {
122 Self::MissingField {
123 playbook_id: playbook_id.into(),
124 field: field.into(),
125 }
126 }
127
128 /// Field path (dotted JSON-pointer-style) when applicable.
129 #[must_use]
130 pub fn field_path(&self) -> Option<&str> {
131 match self {
132 Self::InvalidField { field, .. } | Self::MissingField { field, .. } => Some(field),
133 _ => None,
134 }
135 }
136
137 /// Bad value (string form) when applicable.
138 #[must_use]
139 pub fn bad_value(&self) -> Option<&str> {
140 match self {
141 Self::InvalidField { value, .. } => Some(value),
142 _ => None,
143 }
144 }
145}
146
147#[cfg(test)]
148#[allow(
149 clippy::unwrap_used,
150 clippy::expect_used,
151 clippy::panic,
152 clippy::indexing_slicing
153)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn invalid_field_message_includes_field_and_value() {
159 let err = ValidationError::invalid_field(
160 "tier1-js",
161 "pacing.rate_limit_rps",
162 "-0.5",
163 "must be > 0",
164 );
165 let msg = err.to_string();
166 assert!(msg.contains("tier1-js"));
167 assert!(msg.contains("pacing.rate_limit_rps"));
168 assert!(msg.contains("-0.5"));
169 assert!(msg.contains("must be > 0"));
170 assert_eq!(err.field_path(), Some("pacing.rate_limit_rps"));
171 assert_eq!(err.bad_value(), Some("-0.5"));
172 }
173
174 #[test]
175 fn missing_field_message_includes_field() {
176 let err = ValidationError::missing_field("tier2-hostile", "acquisition.mode");
177 let msg = err.to_string();
178 assert!(msg.contains("tier2-hostile"));
179 assert!(msg.contains("acquisition.mode"));
180 assert_eq!(err.field_path(), Some("acquisition.mode"));
181 assert_eq!(err.bad_value(), None);
182 }
183
184 #[test]
185 fn duplicate_id_does_not_report_field() {
186 let err = ValidationError::DuplicateId {
187 playbook_id: "tier1-static".to_string(),
188 };
189 assert_eq!(err.field_path(), None);
190 assert_eq!(err.bad_value(), None);
191 assert!(err.to_string().contains("tier1-static"));
192 }
193}