Skip to main content

vdsl_sync/domain/
location.rs

1//! Location identifiers and per-location sync summary.
2//!
3//! Locations are string-based for N-remote extensibility.
4//! "local" is reserved as the origin location (developer machine).
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::fmt;
9
10use super::error::DomainError;
11use super::view::{ErrorEntry, PendingEntry};
12
13// =============================================================================
14// LocationId
15// =============================================================================
16
17/// Identifier for a sync location.
18///
19/// String-based to support arbitrary remotes: "pod", "cloud", "staging-pod",
20/// "nas", "s3-archive", etc. `"local"` is reserved as the origin.
21#[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    /// Reserved ID for the local (origin) location.
37    pub const LOCAL: &str = "local";
38
39    /// Create a new LocationId. Empty strings are rejected.
40    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        // Enforce lowercase alphanumeric + hyphens for consistency
46        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    /// The canonical local location.
58    pub fn local() -> Self {
59        Self("local".into())
60    }
61
62    /// Whether this is the local (origin) location.
63    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// =============================================================================
87// LocationSummary / SyncSummary
88// =============================================================================
89
90/// Per-location count of files by state.
91#[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/// Aggregated sync status across all locations.
111#[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    /// 失敗したTransferの詳細(Transfer本体は非公開)。
117    pub error_entries: Vec<ErrorEntry>,
118    /// 待機中Transferの詳細(Transfer本体は非公開)。
119    pub pending_entries: Vec<PendingEntry>,
120}
121
122impl SyncSummary {
123    /// Serialize to [`serde_json::Value`] for cross-boundary transport.
124    pub fn to_value(&self) -> Result<serde_json::Value, serde_json::Error> {
125        serde_json::to_value(self)
126    }
127}
128
129// =============================================================================
130// Tests
131// =============================================================================
132
133#[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()); // uppercase
154        assert!(LocationId::new("my pod").is_err()); // space
155        assert!(LocationId::new("cloud/b2").is_err()); // slash
156    }
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        // Empty string
183        let r: Result<LocationId, _> = serde_json::from_str("\"\"");
184        assert!(r.is_err(), "empty string must be rejected via serde");
185
186        // Uppercase
187        let r: Result<LocationId, _> = serde_json::from_str("\"Pod\"");
188        assert!(r.is_err(), "uppercase must be rejected via serde");
189
190        // Slash
191        let r: Result<LocationId, _> = serde_json::from_str("\"cloud/b2\"");
192        assert!(r.is_err(), "slash must be rejected via serde");
193
194        // Space
195        let r: Result<LocationId, _> = serde_json::from_str("\"my pod\"");
196        assert!(r.is_err(), "space must be rejected via serde");
197    }
198}