1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4use crate::{AuthConfig, CacheConfig};
5
6#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct WorkspaceConfig {
32 pub workspace: String,
34
35 pub services: IndexMap<String, ServiceDefinition>,
37
38 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub shared: Option<SharedConfig>,
41}
42
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct ServiceDefinition {
47 pub path: String,
49
50 #[serde(default = "default_service_port")]
52 pub port: u16,
53
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 pub depends_on: Vec<String>,
57}
58
59fn default_service_port() -> u16 {
60 3000
61}
62
63#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct SharedConfig {
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub cache: Option<CacheConfig>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub auth: Option<AuthConfig>,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct ServiceRegistryEntry {
80 pub name: String,
82
83 pub url: String,
85
86 pub port: u16,
88
89 pub resources: Vec<String>,
91
92 pub protocols: Vec<String>,
94
95 pub status: ServiceStatus,
97
98 pub registered_at: String,
100
101 pub last_heartbeat: String,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "lowercase")]
108pub enum ServiceStatus {
109 Starting,
111 Healthy,
113 Unhealthy,
115 Stopped,
117}
118
119impl std::fmt::Display for ServiceStatus {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 match self {
122 Self::Starting => write!(f, "starting"),
123 Self::Healthy => write!(f, "healthy"),
124 Self::Unhealthy => write!(f, "unhealthy"),
125 Self::Stopped => write!(f, "stopped"),
126 }
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132#[serde(deny_unknown_fields)]
133pub struct InterServiceClientConfig {
134 pub service: String,
136
137 pub base_url: String,
139
140 #[serde(default = "default_client_timeout")]
142 pub timeout_secs: u64,
143
144 #[serde(default = "default_client_retries")]
146 pub retry_count: u32,
147}
148
149fn default_client_timeout() -> u64 {
150 10
151}
152
153fn default_client_retries() -> u32 {
154 3
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn workspace_config_minimal() {
163 let json = r#"{
164 "workspace": "my-platform",
165 "services": {
166 "api": {"path": "services/api"}
167 }
168 }"#;
169 let cfg: WorkspaceConfig = serde_json::from_str(json).unwrap();
170 assert_eq!(cfg.workspace, "my-platform");
171 assert_eq!(cfg.services.len(), 1);
172 let api = cfg.services.get("api").unwrap();
173 assert_eq!(api.path, "services/api");
174 assert_eq!(api.port, 3000);
175 assert!(api.depends_on.is_empty());
176 assert!(cfg.shared.is_none());
177 }
178
179 #[test]
180 fn workspace_config_full() {
181 let json = r#"{
182 "workspace": "my-platform",
183 "services": {
184 "users-api": {
185 "path": "services/users-api",
186 "port": 3001
187 },
188 "orders-api": {
189 "path": "services/orders-api",
190 "port": 3002,
191 "depends_on": ["users-api"]
192 }
193 },
194 "shared": {
195 "cache": {
196 "type": "redis",
197 "url": "redis://localhost:6379"
198 },
199 "auth": {
200 "provider": "jwt",
201 "secret_env": "JWT_SECRET",
202 "expiry": "24h"
203 }
204 }
205 }"#;
206 let cfg: WorkspaceConfig = serde_json::from_str(json).unwrap();
207 assert_eq!(cfg.workspace, "my-platform");
208 assert_eq!(cfg.services.len(), 2);
209
210 let orders = cfg.services.get("orders-api").unwrap();
211 assert_eq!(orders.port, 3002);
212 assert_eq!(orders.depends_on, vec!["users-api"]);
213
214 let shared = cfg.shared.unwrap();
215 assert!(shared.cache.is_some());
216 assert!(shared.auth.is_some());
217 }
218
219 #[test]
220 fn workspace_config_unknown_field_fails() {
221 let json = r#"{
222 "workspace": "test",
223 "services": {},
224 "unknown_field": true
225 }"#;
226 let err = serde_json::from_str::<WorkspaceConfig>(json);
227 assert!(err.is_err());
228 }
229
230 #[test]
231 fn service_registry_entry_serde_roundtrip() {
232 let entry = ServiceRegistryEntry {
233 name: "users-api".to_string(),
234 url: "http://localhost:3001".to_string(),
235 port: 3001,
236 resources: vec!["users".to_string(), "profiles".to_string()],
237 protocols: vec!["rest".to_string()],
238 status: ServiceStatus::Healthy,
239 registered_at: "2026-01-01T00:00:00Z".to_string(),
240 last_heartbeat: "2026-01-01T00:01:00Z".to_string(),
241 };
242 let json = serde_json::to_string(&entry).unwrap();
243 let back: ServiceRegistryEntry = serde_json::from_str(&json).unwrap();
244 assert_eq!(entry, back);
245 }
246
247 #[test]
248 fn service_status_display() {
249 assert_eq!(ServiceStatus::Starting.to_string(), "starting");
250 assert_eq!(ServiceStatus::Healthy.to_string(), "healthy");
251 assert_eq!(ServiceStatus::Unhealthy.to_string(), "unhealthy");
252 assert_eq!(ServiceStatus::Stopped.to_string(), "stopped");
253 }
254
255 #[test]
256 fn inter_service_client_config_defaults() {
257 let json = r#"{"service": "users-api", "base_url": "http://localhost:3001"}"#;
258 let cfg: InterServiceClientConfig = serde_json::from_str(json).unwrap();
259 assert_eq!(cfg.service, "users-api");
260 assert_eq!(cfg.timeout_secs, 10);
261 assert_eq!(cfg.retry_count, 3);
262 }
263
264 #[test]
265 fn service_definition_defaults() {
266 let json = r#"{"path": "services/api"}"#;
267 let svc: ServiceDefinition = serde_json::from_str(json).unwrap();
268 assert_eq!(svc.port, 3000);
269 assert!(svc.depends_on.is_empty());
270 }
271
272 #[test]
273 fn workspace_config_serde_roundtrip() {
274 let cfg = WorkspaceConfig {
275 workspace: "test-workspace".to_string(),
276 services: {
277 let mut m = IndexMap::new();
278 m.insert(
279 "svc-a".to_string(),
280 ServiceDefinition {
281 path: "services/svc-a".to_string(),
282 port: 3001,
283 depends_on: vec![],
284 },
285 );
286 m.insert(
287 "svc-b".to_string(),
288 ServiceDefinition {
289 path: "services/svc-b".to_string(),
290 port: 3002,
291 depends_on: vec!["svc-a".to_string()],
292 },
293 );
294 m
295 },
296 shared: None,
297 };
298 let json = serde_json::to_string(&cfg).unwrap();
299 let back: WorkspaceConfig = serde_json::from_str(&json).unwrap();
300 assert_eq!(cfg, back);
301 }
302}