Skip to main content

pointlock_provider_kit/
error.rs

1//! The unified error carrier thrown by provider methods (04 §1, pending
2//! spine incorporation).
3//!
4//! `execute()`'s four-way terminal outcome is **not** expressed through
5//! [`ProviderError`] — `failed | cancelled | timedOut` are ordinary return
6//! values (a definite thing happened in the world). `ProviderError` only
7//! covers "the call itself could not obtain a terminal outcome": transport
8//! rupture, handshake failure, protocol violation, envelope timeout. This
9//! split is the watershed between the runner's `settling` and `onError`
10//! paths.
11
12use std::fmt;
13
14use pointlock_ir::{ErrorClass, ErrorInfo};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17
18/// Where a `retryable` judgement came from (04 §6.3 audit requirement):
19/// the daemon's own declaration, or the Pointlock classifier's fallback.
20/// Recorded so a classifier's conservative guess is never mistaken for a
21/// substrate fact.
22#[derive(
23    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
24)]
25#[serde(rename_all = "camelCase")]
26pub enum RetryableSource {
27    /// The daemon declared retryability (`ErrorInfo.retryable`).
28    Daemon,
29    /// The Pointlock-side classifier assigned retryability as a fallback.
30    Classifier,
31}
32
33/// Unified provider-method error carrier: wire fact and normalized
34/// conclusion side by side, both recorded in the RunLog (04 §1).
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37pub struct ProviderError {
38    /// Normalized conclusion (spine §5 closed enum).
39    pub error_class: ErrorClass,
40    /// Human-readable message.
41    pub message: String,
42    /// The daemon's original `{ code, message, retryable, details? }`, when
43    /// the failure carried one (boxed: much larger than the other fields).
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub wire: Option<Box<ErrorInfo>>,
46    /// Client-side error code (e.g. `"transport_closed"`; TS reference
47    /// implementation `@devicerail/client` `ClientErrorCode` — the Rust-side
48    /// naming follows the `devicerail-client` crate when it lands, M1).
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub client_code: Option<String>,
51    /// Where the retryable judgement came from.
52    pub retryable_source: RetryableSource,
53}
54
55impl ProviderError {
56    /// Constructs a carrier with neither wire error nor client code.
57    pub fn new(
58        error_class: ErrorClass,
59        message: impl Into<String>,
60        retryable_source: RetryableSource,
61    ) -> Self {
62        ProviderError {
63            error_class,
64            message: message.into(),
65            wire: None,
66            client_code: None,
67            retryable_source,
68        }
69    }
70
71    /// Attaches the daemon's original wire error.
72    pub fn with_wire(mut self, wire: ErrorInfo) -> Self {
73        self.wire = Some(Box::new(wire));
74        self
75    }
76
77    /// Attaches a client-side error code.
78    pub fn with_client_code(mut self, client_code: impl Into<String>) -> Self {
79        self.client_code = Some(client_code.into());
80        self
81    }
82}
83
84impl fmt::Display for ProviderError {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        // Render the class in its wire (snake_case) spelling.
87        let class = serde_json::to_value(self.error_class).expect("ErrorClass serializes");
88        let class = class.as_str().expect("ErrorClass serializes to a string");
89        write!(f, "provider error [{class}]: {}", self.message)
90    }
91}
92
93impl std::error::Error for ProviderError {}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use serde_json::json;
99
100    #[test]
101    fn provider_error_wire_shape() {
102        let error = ProviderError::new(
103            ErrorClass::TransportLost,
104            "daemon exited",
105            RetryableSource::Classifier,
106        )
107        .with_wire(ErrorInfo {
108            code: "session_degraded".to_owned(),
109            message: "adb bridge lost".to_owned(),
110            retryable: true,
111            details: None,
112        })
113        .with_client_code("transport_closed");
114
115        let wire = serde_json::to_value(&error).expect("serialize");
116        assert_eq!(wire["errorClass"], "transport_lost");
117        assert_eq!(wire["retryableSource"], "classifier");
118        assert_eq!(wire["clientCode"], "transport_closed");
119        assert_eq!(wire["wire"]["code"], "session_degraded");
120        let back: ProviderError = serde_json::from_value(wire).expect("deserialize");
121        assert_eq!(back, error);
122    }
123
124    #[test]
125    fn provider_error_optional_fields_absent_when_none() {
126        let error = ProviderError::new(
127            ErrorClass::CapabilityDrift,
128            "digest mismatch",
129            RetryableSource::Classifier,
130        );
131        let wire = serde_json::to_value(&error).expect("serialize");
132        assert_eq!(
133            wire,
134            json!({
135                "errorClass": "capability_drift",
136                "message": "digest mismatch",
137                "retryableSource": "classifier",
138            })
139        );
140    }
141
142    #[test]
143    fn provider_error_display_uses_wire_class_spelling() {
144        let error = ProviderError::new(
145            ErrorClass::ActionTimedOut,
146            "budget elapsed",
147            RetryableSource::Daemon,
148        );
149        assert_eq!(
150            error.to_string(),
151            "provider error [action_timed_out]: budget elapsed"
152        );
153    }
154}