use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::{AuthConfig, CacheConfig};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceConfig {
pub workspace: String,
pub services: IndexMap<String, ServiceDefinition>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shared: Option<SharedConfig>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ServiceDefinition {
pub path: String,
#[serde(default = "default_service_port")]
pub port: u16,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub depends_on: Vec<String>,
}
fn default_service_port() -> u16 {
3000
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SharedConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache: Option<CacheConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthConfig>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServiceRegistryEntry {
pub name: String,
pub url: String,
pub port: u16,
pub resources: Vec<String>,
pub protocols: Vec<String>,
pub status: ServiceStatus,
pub registered_at: String,
pub last_heartbeat: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ServiceStatus {
Starting,
Healthy,
Unhealthy,
Stopped,
}
impl std::fmt::Display for ServiceStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Starting => write!(f, "starting"),
Self::Healthy => write!(f, "healthy"),
Self::Unhealthy => write!(f, "unhealthy"),
Self::Stopped => write!(f, "stopped"),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InterServiceClientConfig {
pub service: String,
pub base_url: String,
#[serde(default = "default_client_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_client_retries")]
pub retry_count: u32,
}
fn default_client_timeout() -> u64 {
10
}
fn default_client_retries() -> u32 {
3
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workspace_config_minimal() {
let json = r#"{
"workspace": "my-platform",
"services": {
"api": {"path": "services/api"}
}
}"#;
let cfg: WorkspaceConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.workspace, "my-platform");
assert_eq!(cfg.services.len(), 1);
let api = cfg.services.get("api").unwrap();
assert_eq!(api.path, "services/api");
assert_eq!(api.port, 3000);
assert!(api.depends_on.is_empty());
assert!(cfg.shared.is_none());
}
#[test]
fn workspace_config_full() {
let json = r#"{
"workspace": "my-platform",
"services": {
"users-api": {
"path": "services/users-api",
"port": 3001
},
"orders-api": {
"path": "services/orders-api",
"port": 3002,
"depends_on": ["users-api"]
}
},
"shared": {
"cache": {
"type": "redis",
"url": "redis://localhost:6379"
},
"auth": {
"provider": "jwt",
"secret_env": "JWT_SECRET",
"expiry": "24h"
}
}
}"#;
let cfg: WorkspaceConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.workspace, "my-platform");
assert_eq!(cfg.services.len(), 2);
let orders = cfg.services.get("orders-api").unwrap();
assert_eq!(orders.port, 3002);
assert_eq!(orders.depends_on, vec!["users-api"]);
let shared = cfg.shared.unwrap();
assert!(shared.cache.is_some());
assert!(shared.auth.is_some());
}
#[test]
fn workspace_config_unknown_field_fails() {
let json = r#"{
"workspace": "test",
"services": {},
"unknown_field": true
}"#;
let err = serde_json::from_str::<WorkspaceConfig>(json);
assert!(err.is_err());
}
#[test]
fn service_registry_entry_serde_roundtrip() {
let entry = ServiceRegistryEntry {
name: "users-api".to_string(),
url: "http://localhost:3001".to_string(),
port: 3001,
resources: vec!["users".to_string(), "profiles".to_string()],
protocols: vec!["rest".to_string()],
status: ServiceStatus::Healthy,
registered_at: "2026-01-01T00:00:00Z".to_string(),
last_heartbeat: "2026-01-01T00:01:00Z".to_string(),
};
let json = serde_json::to_string(&entry).unwrap();
let back: ServiceRegistryEntry = serde_json::from_str(&json).unwrap();
assert_eq!(entry, back);
}
#[test]
fn service_status_display() {
assert_eq!(ServiceStatus::Starting.to_string(), "starting");
assert_eq!(ServiceStatus::Healthy.to_string(), "healthy");
assert_eq!(ServiceStatus::Unhealthy.to_string(), "unhealthy");
assert_eq!(ServiceStatus::Stopped.to_string(), "stopped");
}
#[test]
fn inter_service_client_config_defaults() {
let json = r#"{"service": "users-api", "base_url": "http://localhost:3001"}"#;
let cfg: InterServiceClientConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.service, "users-api");
assert_eq!(cfg.timeout_secs, 10);
assert_eq!(cfg.retry_count, 3);
}
#[test]
fn service_definition_defaults() {
let json = r#"{"path": "services/api"}"#;
let svc: ServiceDefinition = serde_json::from_str(json).unwrap();
assert_eq!(svc.port, 3000);
assert!(svc.depends_on.is_empty());
}
#[test]
fn workspace_config_serde_roundtrip() {
let cfg = WorkspaceConfig {
workspace: "test-workspace".to_string(),
services: {
let mut m = IndexMap::new();
m.insert(
"svc-a".to_string(),
ServiceDefinition {
path: "services/svc-a".to_string(),
port: 3001,
depends_on: vec![],
},
);
m.insert(
"svc-b".to_string(),
ServiceDefinition {
path: "services/svc-b".to_string(),
port: 3002,
depends_on: vec!["svc-a".to_string()],
},
);
m
},
shared: None,
};
let json = serde_json::to_string(&cfg).unwrap();
let back: WorkspaceConfig = serde_json::from_str(&json).unwrap();
assert_eq!(cfg, back);
}
}