ddns_a/state/mod.rs
1//! IP state persistence for detecting changes across restarts.
2//!
3//! This module provides abstractions for storing and retrieving
4//! adapter snapshot state between program executions.
5
6mod file;
7
8#[cfg(test)]
9#[path = "mod_tests.rs"]
10mod tests;
11
12pub use file::FileStateStore;
13
14use std::io;
15
16use thiserror::Error;
17
18use crate::network::AdapterSnapshot;
19
20/// Result of loading state from persistent storage.
21///
22/// Explicitly models all valid states to avoid ambiguity:
23/// - Successfully loaded previous state
24/// - No previous state exists (first run)
25/// - State exists but is corrupted/unreadable
26#[derive(Debug, Clone)]
27pub enum LoadResult {
28 /// Successfully loaded previously saved snapshots.
29 Loaded(Vec<AdapterSnapshot>),
30
31 /// No state file exists (first run or explicitly deleted).
32 NotFound,
33
34 /// State file exists but could not be parsed.
35 /// Program should continue with fresh state and overwrite on next save.
36 Corrupted {
37 /// Reason for corruption (for logging/debugging).
38 reason: String,
39 },
40}
41
42impl LoadResult {
43 /// Returns the loaded snapshots, or an empty vec for `NotFound`/`Corrupted`.
44 #[must_use]
45 pub fn into_snapshots(self) -> Vec<AdapterSnapshot> {
46 match self {
47 Self::Loaded(snapshots) => snapshots,
48 Self::NotFound | Self::Corrupted { .. } => Vec::new(),
49 }
50 }
51
52 /// Returns `true` if state was successfully loaded.
53 #[must_use]
54 pub const fn is_loaded(&self) -> bool {
55 matches!(self, Self::Loaded(_))
56 }
57}
58
59/// Errors that can occur during state persistence operations.
60///
61/// Only covers write-side errors; read-side issues are modeled
62/// as [`LoadResult`] variants to allow graceful degradation.
63#[derive(Debug, Error)]
64pub enum StateError {
65 /// Failed to write the state file.
66 #[error("Failed to write state file: {0}")]
67 Write(#[source] io::Error),
68
69 /// Failed to serialize state to JSON.
70 #[error("Failed to serialize state: {0}")]
71 Serialize(#[source] serde_json::Error),
72}
73
74/// Abstraction for persisting adapter state between program runs.
75///
76/// Implementations should:
77/// - Use atomic writes to prevent corruption from crashes
78/// - Handle missing files gracefully (return `LoadResult::NotFound`)
79/// - Degrade gracefully on read errors (return `LoadResult::Corrupted`)
80///
81/// # Testing
82///
83/// Use [`MockStateStore`] in tests to avoid filesystem dependencies.
84pub trait StateStore: Send + Sync {
85 /// Loads previously saved state.
86 ///
87 /// Returns one of:
88 /// - `LoadResult::Loaded` - State was successfully loaded
89 /// - `LoadResult::NotFound` - No state file exists
90 /// - `LoadResult::Corrupted` - State file exists but is invalid
91 fn load(&self) -> LoadResult;
92
93 /// Saves current adapter state for future reference.
94 ///
95 /// Implementations should use atomic write semantics (write to temp file,
96 /// then rename) to prevent corruption if the program crashes mid-write.
97 ///
98 /// # Errors
99 ///
100 /// Returns an error if the state cannot be written.
101 fn save(
102 &self,
103 snapshots: &[AdapterSnapshot],
104 ) -> impl std::future::Future<Output = Result<(), StateError>> + Send;
105}
106
107/// Mock state store for testing.
108///
109/// Allows tests to inject specific load results and capture saved state.
110#[cfg(test)]
111pub mod mock {
112 use super::*;
113 use std::sync::RwLock;
114
115 /// A mock implementation of [`StateStore`] for testing.
116 #[derive(Debug)]
117 pub struct MockStateStore {
118 load_result: LoadResult,
119 saved: RwLock<Option<Vec<AdapterSnapshot>>>,
120 }
121
122 impl MockStateStore {
123 /// Creates a mock that returns `LoadResult::Loaded` with the given snapshots.
124 #[must_use]
125 pub fn with_loaded(snapshots: Vec<AdapterSnapshot>) -> Self {
126 Self {
127 load_result: LoadResult::Loaded(snapshots),
128 saved: RwLock::new(None),
129 }
130 }
131
132 /// Creates a mock that returns `LoadResult::NotFound`.
133 #[must_use]
134 pub fn not_found() -> Self {
135 Self {
136 load_result: LoadResult::NotFound,
137 saved: RwLock::new(None),
138 }
139 }
140
141 /// Creates a mock that returns `LoadResult::Corrupted`.
142 #[must_use]
143 pub fn corrupted(reason: impl Into<String>) -> Self {
144 Self {
145 load_result: LoadResult::Corrupted {
146 reason: reason.into(),
147 },
148 saved: RwLock::new(None),
149 }
150 }
151
152 /// Returns the last saved snapshots, if any.
153 ///
154 /// # Panics
155 ///
156 /// Panics if the internal lock is poisoned (only in test code).
157 #[must_use]
158 pub fn saved_snapshots(&self) -> Option<Vec<AdapterSnapshot>> {
159 self.saved.read().unwrap().clone()
160 }
161 }
162
163 impl StateStore for MockStateStore {
164 fn load(&self) -> LoadResult {
165 self.load_result.clone()
166 }
167
168 async fn save(&self, snapshots: &[AdapterSnapshot]) -> Result<(), StateError> {
169 *self.saved.write().unwrap() = Some(snapshots.to_vec());
170 Ok(())
171 }
172 }
173}