Skip to main content

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}