Skip to main content

ant_core/node/
registry.rs

1use std::collections::HashMap;
2use std::fs::File;
3use std::path::{Path, PathBuf};
4
5use fs2::FileExt;
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Error, Result};
9use crate::node::types::NodeConfig;
10
11/// Persisted node registry (JSON file on disk).
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct NodeRegistry {
14    pub schema_version: u32,
15    pub(crate) nodes: HashMap<u32, NodeConfig>,
16    pub next_id: u32,
17    /// Path where this registry is persisted. Not serialized.
18    #[serde(skip)]
19    pub path: PathBuf,
20}
21
22impl NodeRegistry {
23    /// Load the registry from disk, or create an empty one if the file doesn't exist.
24    pub fn load(path: &Path) -> Result<Self> {
25        if path.exists() {
26            let contents = std::fs::read_to_string(path)?;
27            let mut registry: Self = serde_json::from_str(&contents)?;
28            registry.path = path.to_path_buf();
29            Ok(registry)
30        } else {
31            Ok(Self {
32                schema_version: 1,
33                nodes: HashMap::new(),
34                next_id: 1,
35                path: path.to_path_buf(),
36            })
37        }
38    }
39
40    /// Load the registry with an exclusive file lock.
41    ///
42    /// Returns the registry and the lock file handle. The lock is held until the
43    /// file handle is dropped, so callers should keep it alive for the duration of
44    /// their read-modify-write cycle.
45    pub fn load_locked(path: &Path) -> Result<(Self, File)> {
46        if let Some(parent) = path.parent() {
47            std::fs::create_dir_all(parent)?;
48        }
49
50        let lock_path = path.with_extension("lock");
51        let lock_file = File::create(&lock_path)?;
52        lock_file.lock_exclusive()?;
53
54        let registry = Self::load(path)?;
55        Ok((registry, lock_file))
56    }
57
58    /// Save the registry to disk atomically.
59    ///
60    /// Writes to a temporary file first, then renames to the target path.
61    /// This prevents registry corruption if the process crashes mid-write.
62    pub fn save(&self) -> Result<()> {
63        if let Some(parent) = self.path.parent() {
64            std::fs::create_dir_all(parent)?;
65        }
66        let contents = serde_json::to_string_pretty(self)?;
67        let tmp_path = self.path.with_extension("tmp");
68        std::fs::write(&tmp_path, &contents)?;
69        std::fs::rename(&tmp_path, &self.path)?;
70        Ok(())
71    }
72
73    /// Get a node by ID.
74    pub fn get(&self, id: u32) -> Result<&NodeConfig> {
75        self.nodes.get(&id).ok_or(Error::NodeNotFound(id))
76    }
77
78    /// Get a mutable reference to a node by ID.
79    pub fn get_mut(&mut self, id: u32) -> Result<&mut NodeConfig> {
80        self.nodes.get_mut(&id).ok_or(Error::NodeNotFound(id))
81    }
82
83    /// Add a node and return its assigned ID.
84    pub fn add(&mut self, mut config: NodeConfig) -> u32 {
85        let id = self.next_id;
86        self.next_id += 1;
87        config.id = id;
88        config.service_name = format!("node{id}");
89        self.nodes.insert(id, config);
90        id
91    }
92
93    /// Add multiple nodes at once and return their assigned IDs.
94    pub fn add_batch(&mut self, configs: Vec<NodeConfig>) -> Vec<u32> {
95        configs.into_iter().map(|config| self.add(config)).collect()
96    }
97
98    /// Remove a node by ID.
99    pub fn remove(&mut self, id: u32) -> Result<NodeConfig> {
100        self.nodes.remove(&id).ok_or(Error::NodeNotFound(id))
101    }
102
103    /// List all nodes.
104    pub fn list(&self) -> Vec<&NodeConfig> {
105        let mut nodes: Vec<_> = self.nodes.values().collect();
106        nodes.sort_by_key(|n| n.id);
107        nodes
108    }
109
110    /// Find a node by its service name.
111    pub fn find_by_service_name(&self, name: &str) -> Option<&NodeConfig> {
112        self.nodes.values().find(|n| n.service_name == name)
113    }
114
115    /// Number of registered nodes.
116    pub fn len(&self) -> usize {
117        self.nodes.len()
118    }
119
120    /// Whether the registry is empty.
121    pub fn is_empty(&self) -> bool {
122        self.nodes.is_empty()
123    }
124
125    /// Clear all nodes and reset the next ID counter.
126    pub fn clear(&mut self) {
127        self.nodes.clear();
128        self.next_id = 1;
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::collections::HashMap;
136    use tempfile::NamedTempFile;
137
138    fn make_config(id: u32) -> NodeConfig {
139        NodeConfig {
140            id,
141            service_name: String::new(),
142            rewards_address: "0xtest".to_string(),
143            data_dir: PathBuf::from("/tmp/test"),
144            log_dir: None,
145            node_port: None,
146            metrics_port: None,
147            network_id: None,
148            binary_path: PathBuf::from("/usr/bin/antnode"),
149            version: "0.1.0".to_string(),
150            env_variables: HashMap::new(),
151            bootstrap_peers: vec![],
152        }
153    }
154
155    #[test]
156    fn load_creates_empty_registry() {
157        let tmp = NamedTempFile::new().unwrap();
158        let path = tmp.path().with_extension("json");
159        // File doesn't exist at this path
160        let reg = NodeRegistry::load(&path).unwrap();
161        assert!(reg.is_empty());
162        assert_eq!(reg.next_id, 1);
163    }
164
165    #[test]
166    fn add_and_get() {
167        let tmp = NamedTempFile::new().unwrap();
168        let path = tmp.path().with_extension("json");
169        let mut reg = NodeRegistry::load(&path).unwrap();
170        let id = reg.add(make_config(0));
171        assert_eq!(id, 1);
172        assert_eq!(reg.get(id).unwrap().rewards_address, "0xtest");
173    }
174
175    #[test]
176    fn save_and_reload() {
177        let tmp = NamedTempFile::new().unwrap();
178        let path = tmp.path().with_extension("json");
179        let mut reg = NodeRegistry::load(&path).unwrap();
180        reg.add(make_config(0));
181        reg.save().unwrap();
182
183        let reg2 = NodeRegistry::load(&path).unwrap();
184        assert_eq!(reg2.len(), 1);
185    }
186
187    #[test]
188    fn add_batch_assigns_sequential_ids() {
189        let tmp = NamedTempFile::new().unwrap();
190        let path = tmp.path().with_extension("json");
191        let mut reg = NodeRegistry::load(&path).unwrap();
192        let configs = vec![make_config(0), make_config(0), make_config(0)];
193        let ids = reg.add_batch(configs);
194        assert_eq!(ids, vec![1, 2, 3]);
195        assert_eq!(reg.len(), 3);
196        assert_eq!(reg.next_id, 4);
197    }
198
199    #[test]
200    fn load_locked_creates_lock_file() {
201        let tmp = NamedTempFile::new().unwrap();
202        let path = tmp.path().with_extension("json");
203        let (reg, _lock) = NodeRegistry::load_locked(&path).unwrap();
204        assert!(reg.is_empty());
205        assert!(path.with_extension("lock").exists());
206    }
207
208    #[test]
209    fn remove_returns_config() {
210        let tmp = NamedTempFile::new().unwrap();
211        let path = tmp.path().with_extension("json");
212        let mut reg = NodeRegistry::load(&path).unwrap();
213        let id = reg.add(make_config(0));
214        let removed = reg.remove(id).unwrap();
215        assert_eq!(removed.rewards_address, "0xtest");
216        assert!(reg.is_empty());
217    }
218
219    #[test]
220    fn remove_missing_node_errors() {
221        let tmp = NamedTempFile::new().unwrap();
222        let path = tmp.path().with_extension("json");
223        let mut reg = NodeRegistry::load(&path).unwrap();
224        let result = reg.remove(999);
225        assert!(result.is_err());
226    }
227
228    #[test]
229    fn clear_empties_registry_and_resets_next_id() {
230        let tmp = NamedTempFile::new().unwrap();
231        let path = tmp.path().with_extension("json");
232        let mut reg = NodeRegistry::load(&path).unwrap();
233        reg.add(make_config(0));
234        reg.add(make_config(0));
235        assert_eq!(reg.len(), 2);
236        assert_eq!(reg.next_id, 3);
237
238        reg.clear();
239        assert!(reg.is_empty());
240        assert_eq!(reg.next_id, 1);
241    }
242
243    #[test]
244    fn save_is_atomic_no_tmp_file_remains() {
245        let tmp = NamedTempFile::new().unwrap();
246        let path = tmp.path().with_extension("json");
247        let mut reg = NodeRegistry::load(&path).unwrap();
248        reg.add(make_config(0));
249        reg.save().unwrap();
250
251        // The temp file should not remain after a successful save
252        assert!(path.exists());
253        assert!(!path.with_extension("tmp").exists());
254    }
255}