vdsl_sync/domain/
location.rs1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9
10use super::error::DomainError;
11use super::view::{ErrorEntry, PendingEntry};
12
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
22#[serde(transparent)]
23pub struct LocationId(String);
24
25impl<'de> Deserialize<'de> for LocationId {
26 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
27 where
28 D: serde::Deserializer<'de>,
29 {
30 let s = String::deserialize(deserializer)?;
31 Self::new(s).map_err(serde::de::Error::custom)
32 }
33}
34
35impl LocationId {
36 pub const LOCAL: &str = "local";
38
39 pub fn new(id: impl Into<String>) -> Result<Self, DomainError> {
41 let id = id.into();
42 if id.is_empty() {
43 return Err(DomainError::InvalidLocation("empty location id".into()));
44 }
45 if !id
47 .chars()
48 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
49 {
50 return Err(DomainError::InvalidLocation(format!(
51 "location id must be lowercase alphanumeric with hyphens/underscores: {id}"
52 )));
53 }
54 Ok(Self(id))
55 }
56
57 pub fn local() -> Self {
59 Self("local".into())
60 }
61
62 pub fn is_local(&self) -> bool {
64 self.0 == Self::LOCAL
65 }
66
67 pub fn as_str(&self) -> &str {
68 &self.0
69 }
70}
71
72impl fmt::Display for LocationId {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 f.write_str(&self.0)
75 }
76}
77
78impl std::str::FromStr for LocationId {
79 type Err = DomainError;
80
81 fn from_str(s: &str) -> Result<Self, Self::Err> {
82 Self::new(s)
83 }
84}
85
86#[derive(Debug, Clone, Default, Serialize, Deserialize)]
92pub struct LocationSummary {
93 pub present: usize,
94 pub pending: usize,
95 pub syncing: usize,
96 pub failed: usize,
97 pub absent: usize,
98}
99
100impl LocationSummary {
101 pub fn total(&self) -> usize {
102 self.present
103 .saturating_add(self.pending)
104 .saturating_add(self.syncing)
105 .saturating_add(self.failed)
106 .saturating_add(self.absent)
107 }
108}
109
110#[derive(Debug, Clone, Default, Serialize, Deserialize)]
112pub struct SyncSummary {
113 pub locations: HashMap<LocationId, LocationSummary>,
114 pub total_entries: usize,
115 pub total_errors: usize,
116 pub error_entries: Vec<ErrorEntry>,
118 pub pending_entries: Vec<PendingEntry>,
120}
121
122impl SyncSummary {
123 pub fn to_value(&self) -> Result<serde_json::Value, serde_json::Error> {
125 serde_json::to_value(self)
126 }
127}
128
129#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn location_id_valid() {
139 assert!(LocationId::new("pod").is_ok());
140 assert!(LocationId::new("cloud").is_ok());
141 assert!(LocationId::new("staging-pod").is_ok());
142 assert!(LocationId::new("s3_archive").is_ok());
143 assert!(LocationId::new("nas2").is_ok());
144 }
145
146 #[test]
147 fn location_id_empty_rejected() {
148 assert!(LocationId::new("").is_err());
149 }
150
151 #[test]
152 fn location_id_invalid_chars_rejected() {
153 assert!(LocationId::new("Pod").is_err()); assert!(LocationId::new("my pod").is_err()); assert!(LocationId::new("cloud/b2").is_err()); }
157
158 #[test]
159 fn location_id_local() {
160 let loc = LocationId::local();
161 assert!(loc.is_local());
162 assert_eq!(loc.as_str(), "local");
163 }
164
165 #[test]
166 fn location_id_non_local() {
167 let loc = LocationId::new("pod").unwrap();
168 assert!(!loc.is_local());
169 }
170
171 #[test]
172 fn location_id_serde() {
173 let loc = LocationId::new("pod").unwrap();
174 let json = serde_json::to_string(&loc).unwrap();
175 assert_eq!(json, "\"pod\"");
176 let back: LocationId = serde_json::from_str(&json).unwrap();
177 assert_eq!(back, loc);
178 }
179
180 #[test]
181 fn location_id_serde_rejects_invalid() {
182 let r: Result<LocationId, _> = serde_json::from_str("\"\"");
184 assert!(r.is_err(), "empty string must be rejected via serde");
185
186 let r: Result<LocationId, _> = serde_json::from_str("\"Pod\"");
188 assert!(r.is_err(), "uppercase must be rejected via serde");
189
190 let r: Result<LocationId, _> = serde_json::from_str("\"cloud/b2\"");
192 assert!(r.is_err(), "slash must be rejected via serde");
193
194 let r: Result<LocationId, _> = serde_json::from_str("\"my pod\"");
196 assert!(r.is_err(), "space must be rejected via serde");
197 }
198}