1use serde::{Deserialize, Serialize};
2
3const DEFAULT_BUFFER_CAPACITY: usize = 1000;
5
6const DEFAULT_BATCH_SIZE: usize = 100;
8const DEFAULT_OUTBOX_CAPACITY: usize = 10_000;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct SyncConfig {
27 pub agent_id: String,
29 pub tenant_id: String,
31 pub store_id: String,
33 pub buffer_capacity: usize,
35 pub batch_size: usize,
37 pub outbox_capacity: usize,
39 pub outbox_path: Option<String>,
41}
42
43impl SyncConfig {
44 #[must_use]
46 pub fn new(
47 agent_id: impl Into<String>,
48 tenant_id: impl Into<String>,
49 store_id: impl Into<String>,
50 ) -> Self {
51 Self {
52 agent_id: agent_id.into(),
53 tenant_id: tenant_id.into(),
54 store_id: store_id.into(),
55 buffer_capacity: DEFAULT_BUFFER_CAPACITY,
56 batch_size: DEFAULT_BATCH_SIZE,
57 outbox_capacity: DEFAULT_OUTBOX_CAPACITY,
58 outbox_path: None,
59 }
60 }
61
62 #[must_use]
64 pub const fn with_buffer_capacity(mut self, capacity: usize) -> Self {
65 self.buffer_capacity = capacity;
66 self
67 }
68
69 #[must_use]
71 pub const fn with_batch_size(mut self, batch_size: usize) -> Self {
72 self.batch_size = batch_size;
73 self
74 }
75
76 #[must_use]
78 pub const fn with_outbox_capacity(mut self, capacity: usize) -> Self {
79 self.outbox_capacity = capacity;
80 self
81 }
82
83 #[must_use]
85 pub fn with_outbox_path(mut self, path: impl Into<String>) -> Self {
86 self.outbox_path = Some(path.into());
87 self
88 }
89
90 #[must_use]
92 pub fn resolved_buffer_capacity(&self) -> usize {
93 self.buffer_capacity.max(1)
94 }
95
96 #[must_use]
98 pub fn resolved_batch_size(&self) -> usize {
99 self.batch_size.max(1)
100 }
101
102 #[must_use]
104 pub fn resolved_outbox_capacity(&self) -> usize {
105 self.outbox_capacity.max(1)
106 }
107
108 pub fn validate(&self) -> Result<(), crate::SyncError> {
114 if self.agent_id.trim().is_empty() {
115 return Err(crate::SyncError::InvalidConfig("agent_id must not be empty".into()));
116 }
117 if self.tenant_id.trim().is_empty() {
118 return Err(crate::SyncError::InvalidConfig("tenant_id must not be empty".into()));
119 }
120 if self.store_id.trim().is_empty() {
121 return Err(crate::SyncError::InvalidConfig("store_id must not be empty".into()));
122 }
123 if self.buffer_capacity == 0 {
124 return Err(crate::SyncError::InvalidConfig(
125 "buffer_capacity must be greater than 0".into(),
126 ));
127 }
128 if self.batch_size == 0 {
129 return Err(crate::SyncError::InvalidConfig(
130 "batch_size must be greater than 0".into(),
131 ));
132 }
133 if self.outbox_capacity == 0 {
134 return Err(crate::SyncError::InvalidConfig(
135 "outbox_capacity must be greater than 0".into(),
136 ));
137 }
138 if self.outbox_path.as_ref().is_some_and(|path| path.trim().is_empty()) {
139 return Err(crate::SyncError::InvalidConfig(
140 "outbox_path must not be empty when provided".into(),
141 ));
142 }
143 Ok(())
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn new_config_defaults() {
153 let config = SyncConfig::new("a", "t", "s");
154 assert_eq!(config.agent_id, "a");
155 assert_eq!(config.tenant_id, "t");
156 assert_eq!(config.store_id, "s");
157 assert_eq!(config.buffer_capacity, DEFAULT_BUFFER_CAPACITY);
158 assert_eq!(config.batch_size, DEFAULT_BATCH_SIZE);
159 assert_eq!(config.outbox_capacity, DEFAULT_OUTBOX_CAPACITY);
160 assert!(config.outbox_path.is_none());
161 }
162
163 #[test]
164 fn config_builder_pattern() {
165 let config = SyncConfig::new("a", "t", "s")
166 .with_buffer_capacity(500)
167 .with_batch_size(50)
168 .with_outbox_capacity(900)
169 .with_outbox_path("/tmp/sync-outbox.json");
170 assert_eq!(config.buffer_capacity, 500);
171 assert_eq!(config.batch_size, 50);
172 assert_eq!(config.outbox_capacity, 900);
173 assert_eq!(config.outbox_path.as_deref(), Some("/tmp/sync-outbox.json"));
174 }
175
176 #[test]
177 fn config_serde_roundtrip() {
178 let config = SyncConfig::new("agent-1", "tenant-1", "store-1");
179 let json = serde_json::to_string(&config).unwrap();
180 let deserialized: SyncConfig = serde_json::from_str(&json).unwrap();
181 assert_eq!(deserialized.agent_id, config.agent_id);
182 assert_eq!(deserialized.tenant_id, config.tenant_id);
183 assert_eq!(deserialized.store_id, config.store_id);
184 assert_eq!(deserialized.buffer_capacity, config.buffer_capacity);
185 assert_eq!(deserialized.batch_size, config.batch_size);
186 assert_eq!(deserialized.outbox_capacity, config.outbox_capacity);
187 assert_eq!(deserialized.outbox_path, config.outbox_path);
188 }
189
190 #[test]
191 fn config_clone() {
192 let config = SyncConfig::new("a", "t", "s");
193 let cloned = config.clone();
194 assert_eq!(cloned.agent_id, config.agent_id);
195 }
196
197 #[test]
198 fn config_debug() {
199 let config = SyncConfig::new("a", "t", "s");
200 let debug = format!("{config:?}");
201 assert!(debug.contains("SyncConfig"));
202 assert!(debug.contains("agent_id"));
203 }
204
205 #[test]
206 fn validate_rejects_empty_ids_and_zero_caps() {
207 let bad = SyncConfig::new("", "tenant", "store");
208 assert!(bad.validate().is_err());
209
210 let bad = SyncConfig::new("agent", "", "store");
211 assert!(bad.validate().is_err());
212
213 let bad = SyncConfig::new("agent", "tenant", "")
214 .with_batch_size(0)
215 .with_buffer_capacity(0)
216 .with_outbox_capacity(0);
217 assert!(bad.validate().is_err());
218 }
219
220 #[test]
221 fn validate_accepts_good_config() {
222 let ok = SyncConfig::new("agent", "tenant", "store")
223 .with_buffer_capacity(100)
224 .with_batch_size(10)
225 .with_outbox_capacity(1000)
226 .with_outbox_path("/tmp/outbox.json");
227 assert!(ok.validate().is_ok());
228 }
229}