1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16use crate::dispenser::IdMode;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct NodeConfig {
25 pub node_id: u32,
27 pub user_id: u32,
29 pub hostname: String,
31 pub registered_at: DateTime<Utc>,
33}
34
35impl NodeConfig {
36 #[cfg(feature = "native")]
38 pub fn load(path: &Path) -> anyhow::Result<Self> {
39 let content = std::fs::read_to_string(path)?;
40 let config: NodeConfig = toml::from_str(&content)?;
41 Ok(config)
42 }
43
44 #[cfg(feature = "native")]
46 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
47 if let Some(parent) = path.parent() {
48 std::fs::create_dir_all(parent)?;
49 }
50 let content = toml::to_string_pretty(self)?;
51 std::fs::write(path, content)?;
52 Ok(())
53 }
54
55 pub fn id_mode(&self) -> IdMode {
57 IdMode::Distributed {
58 node_id: self.node_id,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct NodeRegistryEntry {
66 pub id: u32,
68 pub user_id: u32,
70 pub hostname: String,
72 pub registered: DateTime<Utc>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct NodeRegistry {
79 #[serde(default)]
81 pub nodes: Vec<NodeRegistryEntry>,
82}
83
84impl NodeRegistry {
85 pub fn next_node_id(&self) -> u32 {
87 self.nodes.iter().map(|n| n.id).max().unwrap_or(0) + 1
88 }
89
90 pub fn is_registered(&self, node_id: u32) -> bool {
92 self.nodes.iter().any(|n| n.id == node_id)
93 }
94
95 pub fn get(&self, node_id: u32) -> Option<&NodeRegistryEntry> {
97 self.nodes.iter().find(|n| n.id == node_id)
98 }
99
100 pub fn register(&mut self, user_id: u32, hostname: String) -> u32 {
102 let id = self.next_node_id();
103 self.nodes.push(NodeRegistryEntry {
104 id,
105 user_id,
106 hostname,
107 registered: Utc::now(),
108 });
109 id
110 }
111
112 #[cfg(feature = "native")]
114 pub fn load(path: &Path) -> anyhow::Result<Self> {
115 if !path.exists() {
116 return Ok(Self::default());
117 }
118 let content = std::fs::read_to_string(path)?;
119 let registry: NodeRegistry = toml::from_str(&content)?;
120 Ok(registry)
121 }
122
123 #[cfg(feature = "native")]
125 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
126 if let Some(parent) = path.parent() {
127 std::fs::create_dir_all(parent)?;
128 }
129 let content = toml::to_string_pretty(self)?;
130 std::fs::write(path, content)?;
131 Ok(())
132 }
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct UserRegistryEntry {
142 pub id: u32,
144 pub name: String,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub email: Option<String>,
149 pub registered: DateTime<Utc>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, Default)]
155pub struct UserRegistry {
156 #[serde(default)]
157 pub users: Vec<UserRegistryEntry>,
158}
159
160impl UserRegistry {
161 pub fn next_user_id(&self) -> u32 {
163 self.users.iter().map(|u| u.id).max().unwrap_or(0) + 1
164 }
165
166 pub fn register(&mut self, name: String, email: Option<String>) -> u32 {
168 let id = self.next_user_id();
169 self.users.push(UserRegistryEntry {
170 id,
171 name,
172 email,
173 registered: Utc::now(),
174 });
175 id
176 }
177
178 pub fn find_by_name(&self, name: &str) -> Option<&UserRegistryEntry> {
180 self.users
181 .iter()
182 .find(|u| u.name.eq_ignore_ascii_case(name))
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, Default)]
192pub struct AgreedCounters {
193 #[serde(flatten)]
196 pub counters: std::collections::HashMap<String, u32>,
197}
198
199impl AgreedCounters {
200 pub fn next(&mut self, type_prefix: &str) -> u32 {
202 let counter = self
203 .counters
204 .entry(type_prefix.to_uppercase())
205 .or_insert(0);
206 *counter += 1;
207 *counter
208 }
209
210 pub fn peek(&self, type_prefix: &str) -> u32 {
212 self.counters
213 .get(&type_prefix.to_uppercase())
214 .copied()
215 .unwrap_or(0)
216 + 1
217 }
218
219 pub fn format_agreed_id(type_prefix: &str, seq: u32) -> String {
221 format!("{}-{}", type_prefix.to_uppercase(), seq)
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct WorkspaceConfig {
236 pub workspace: String,
238 #[serde(default = "default_aida_path")]
240 pub aida_path: String,
241 #[serde(default)]
243 pub repos: Vec<String>,
244}
245
246fn default_aida_path() -> String {
247 "./aida".to_string()
248}
249
250impl WorkspaceConfig {
251 #[cfg(feature = "native")]
254 pub fn discover(from: &Path) -> Option<(PathBuf, Self)> {
255 let mut current = from.to_path_buf();
256 loop {
257 let candidate = current.join(".aida-workspace");
258 if candidate.exists() {
259 if let Ok(content) = std::fs::read_to_string(&candidate) {
260 if let Ok(config) = toml::from_str::<WorkspaceConfig>(&content) {
261 return Some((current, config));
262 }
263 }
264 }
265 if !current.pop() {
266 return None;
267 }
268 }
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(tag = "mode")]
282pub enum DeploymentMode {
283 #[serde(rename = "centralized")]
287 Centralized,
288
289 #[serde(rename = "distributed")]
292 Distributed {
293 #[serde(default)]
295 aida_repo_path: Option<String>,
296 },
297}
298
299impl Default for DeploymentMode {
300 fn default() -> Self {
301 Self::Centralized
302 }
303}
304
305impl DeploymentMode {
306 pub fn is_distributed(&self) -> bool {
308 matches!(self, Self::Distributed { .. })
309 }
310
311 pub fn id_mode(&self, node_id: Option<u32>) -> IdMode {
315 match self {
316 Self::Centralized => IdMode::Centralized,
317 Self::Distributed { .. } => IdMode::Distributed {
318 node_id: node_id.expect("distributed mode requires a node_id"),
319 },
320 }
321 }
322}
323
324#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn test_node_registry_sequential_ids() {
334 let mut registry = NodeRegistry::default();
335 let id1 = registry.register(1, "laptop".into());
336 let id2 = registry.register(1, "workstation".into());
337 let id3 = registry.register(2, "alice-dev".into());
338 assert_eq!(id1, 1);
339 assert_eq!(id2, 2);
340 assert_eq!(id3, 3);
341 assert!(registry.is_registered(1));
342 assert!(!registry.is_registered(99));
343 }
344
345 #[test]
346 fn test_user_registry() {
347 let mut registry = UserRegistry::default();
348 let id1 = registry.register("Joe".into(), Some("joe@example.com".into()));
349 let id2 = registry.register("Alice".into(), None);
350 assert_eq!(id1, 1);
351 assert_eq!(id2, 2);
352 assert!(registry.find_by_name("joe").is_some());
353 assert!(registry.find_by_name("JOE").is_some()); assert!(registry.find_by_name("bob").is_none());
355 }
356
357 #[test]
358 fn test_agreed_counters() {
359 let mut counters = AgreedCounters::default();
360 assert_eq!(counters.peek("FR"), 1);
361 assert_eq!(counters.next("FR"), 1);
362 assert_eq!(counters.next("FR"), 2);
363 assert_eq!(counters.next("FEAT"), 1);
364 assert_eq!(counters.peek("FR"), 3);
365
366 assert_eq!(
367 AgreedCounters::format_agreed_id("FR", 423),
368 "FR-423"
369 );
370 }
371
372 #[test]
373 fn test_deployment_mode_centralized() {
374 let mode = DeploymentMode::Centralized;
375 assert!(!mode.is_distributed());
376 assert_eq!(mode.id_mode(None), IdMode::Centralized);
377 }
378
379 #[test]
380 fn test_deployment_mode_distributed() {
381 let mode = DeploymentMode::Distributed {
382 aida_repo_path: Some("./aida".into()),
383 };
384 assert!(mode.is_distributed());
385 assert_eq!(
386 mode.id_mode(Some(7)),
387 IdMode::Distributed { node_id: 7 }
388 );
389 }
390
391 #[test]
392 fn test_deployment_mode_serde_roundtrip() {
393 let centralized = DeploymentMode::Centralized;
394 let json = serde_json::to_string(¢ralized).unwrap();
395 assert_eq!(json, r#"{"mode":"centralized"}"#);
396
397 let distributed = DeploymentMode::Distributed {
398 aida_repo_path: Some("./aida".into()),
399 };
400 let json = serde_json::to_string(&distributed).unwrap();
401 let back: DeploymentMode = serde_json::from_str(&json).unwrap();
402 assert_eq!(back, distributed);
403 }
404
405 #[cfg(feature = "native")]
406 #[test]
407 fn test_node_config_persistence() {
408 let dir = tempfile::tempdir().unwrap();
409 let path = dir.path().join("node.toml");
410
411 let config = NodeConfig {
412 node_id: 7,
413 user_id: 102,
414 hostname: "joe-laptop".into(),
415 registered_at: Utc::now(),
416 };
417 config.save(&path).unwrap();
418
419 let loaded = NodeConfig::load(&path).unwrap();
420 assert_eq!(loaded.node_id, 7);
421 assert_eq!(loaded.user_id, 102);
422 assert_eq!(loaded.hostname, "joe-laptop");
423 }
424
425 #[cfg(feature = "native")]
426 #[test]
427 fn test_node_registry_persistence() {
428 let dir = tempfile::tempdir().unwrap();
429 let path = dir.path().join("nodes.toml");
430
431 let mut registry = NodeRegistry::default();
432 registry.register(102, "joe-laptop".into());
433 registry.register(102, "joe-workstation".into());
434 registry.save(&path).unwrap();
435
436 let loaded = NodeRegistry::load(&path).unwrap();
437 assert_eq!(loaded.nodes.len(), 2);
438 assert_eq!(loaded.nodes[0].hostname, "joe-laptop");
439 assert_eq!(loaded.nodes[1].hostname, "joe-workstation");
440 }
441
442 #[cfg(feature = "native")]
443 #[test]
444 fn test_workspace_config_serde() {
445 let config = WorkspaceConfig {
446 workspace: "gdms-disruptive".into(),
447 aida_path: "./aida".into(),
448 repos: vec!["pacgate".into(), "pacinet".into()],
449 };
450 let toml_str = toml::to_string_pretty(&config).unwrap();
451 let back: WorkspaceConfig = toml::from_str(&toml_str).unwrap();
452 assert_eq!(back.workspace, "gdms-disruptive");
453 assert_eq!(back.repos.len(), 2);
454 }
455}