Skip to main content

lifeloop/
renewal_status.rs

1//! Token-safe renewal automation status.
2
3use serde::{Deserialize, Serialize};
4
5use crate::{FailureClass, RetryClass, SCHEMA_VERSION, ValidationError, require_non_empty};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum RenewalAutomationState {
10    NotAttempted,
11    NoRenewal,
12    PrepareStarted,
13    ReceiptWritten,
14    LeaseCreated,
15    PendingContinuation,
16    Fulfilled,
17    Failed,
18    Expired,
19}
20
21impl RenewalAutomationState {
22    pub const ALL: &'static [Self] = &[
23        Self::NotAttempted,
24        Self::NoRenewal,
25        Self::PrepareStarted,
26        Self::ReceiptWritten,
27        Self::LeaseCreated,
28        Self::PendingContinuation,
29        Self::Fulfilled,
30        Self::Failed,
31        Self::Expired,
32    ];
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct RenewalAutomationStatus {
38    pub schema_version: String,
39    pub state: RenewalAutomationState,
40    pub client_id: String,
41    pub adapter_id: String,
42    pub updated_at_epoch_s: u64,
43    pub pending_token_present: bool,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub reset_path: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub thread_id: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub renewal_lease_id: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub prepared_at_epoch_s: Option<u64>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub fulfilled_at_epoch_s: Option<u64>,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub pending_path: Option<String>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub reset_prepare_receipt_path: Option<String>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub failure_class: Option<FailureClass>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub retry_class: Option<RetryClass>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub message: Option<String>,
64}
65
66impl RenewalAutomationStatus {
67    pub fn validate(&self) -> Result<(), ValidationError> {
68        if self.schema_version != SCHEMA_VERSION {
69            return Err(ValidationError::SchemaVersionMismatch {
70                expected: SCHEMA_VERSION.to_string(),
71                found: self.schema_version.clone(),
72            });
73        }
74        require_non_empty(&self.client_id, "renewal_status.client_id")?;
75        require_non_empty(&self.adapter_id, "renewal_status.adapter_id")?;
76        for (field, value) in [
77            ("renewal_status.reset_path", &self.reset_path),
78            ("renewal_status.thread_id", &self.thread_id),
79            ("renewal_status.renewal_lease_id", &self.renewal_lease_id),
80            ("renewal_status.pending_path", &self.pending_path),
81            (
82                "renewal_status.reset_prepare_receipt_path",
83                &self.reset_prepare_receipt_path,
84            ),
85            ("renewal_status.message", &self.message),
86        ] {
87            if let Some(value) = value {
88                require_non_empty(value, field)?;
89            }
90        }
91
92        let failure_like = matches!(
93            self.state,
94            RenewalAutomationState::Failed | RenewalAutomationState::Expired
95        );
96        match (failure_like, self.failure_class, self.retry_class) {
97            (true, Some(_), Some(_)) => {}
98            (true, _, _) => {
99                return Err(ValidationError::InvalidRequest(
100                    "renewal_status state=failed|expired requires failure_class and retry_class"
101                        .into(),
102                ));
103            }
104            (false, None, None) => {}
105            (false, _, _) => {
106                return Err(ValidationError::InvalidRequest(
107                    "failure_class and retry_class are only valid for failed or expired renewal status"
108                        .into(),
109                ));
110            }
111        }
112
113        if matches!(self.state, RenewalAutomationState::PendingContinuation)
114            && !self.pending_token_present
115        {
116            return Err(ValidationError::InvalidRequest(
117                "pending_continuation requires pending_token_present=true".into(),
118            ));
119        }
120
121        Ok(())
122    }
123}