use std::path::PathBuf;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct CodingAgentsConfig {
pub enabled: bool,
#[serde(rename = "maxConcurrentTasks")]
pub max_concurrent_tasks: u32,
#[serde(rename = "defaultTimeoutSecs")]
pub default_timeout_secs: u64,
#[serde(rename = "progressIntervalSecs")]
pub progress_interval_secs: u64,
pub agents: Vec<CodingAgentInstanceConfig>,
pub backends: Vec<BackendDefinition>,
}
impl Default for CodingAgentsConfig {
fn default() -> Self {
Self {
enabled: false,
max_concurrent_tasks: 3,
default_timeout_secs: 1800,
progress_interval_secs: 30,
agents: Vec::new(),
backends: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CodingAgentInstanceConfig {
pub id: String,
#[serde(rename = "backendType")]
pub backend_type: String,
#[serde(default)]
pub endpoint: String,
#[serde(default)]
pub transport: Option<AgentTransport>,
pub workspaces: Vec<PathBuf>,
#[serde(rename = "timeoutSecs")]
pub timeout_secs: Option<u64>,
#[serde(rename = "costCapUsd")]
pub cost_cap_usd: Option<f64>,
#[serde(rename = "monthlyBudgetUsd")]
pub monthly_budget_usd: Option<f64>,
pub alias: Option<String>,
pub auth: Option<AgentAuthConfig>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum AgentTransport {
Stdio {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: std::collections::HashMap<String, String>,
},
Http {
url: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BackendDefinition {
#[serde(rename = "agentType")]
pub agent_type: String,
#[serde(rename = "displayName")]
pub display_name: String,
#[serde(rename = "cliCommand")]
pub cli_command: String,
#[serde(rename = "installCheckCommand")]
pub install_check_command: String,
#[serde(rename = "authMethod")]
pub auth_method: AuthMethod,
pub capabilities: AgentCapabilities,
#[serde(rename = "installInstructions")]
pub install_instructions: String,
#[serde(default, rename = "installInstructionsWindows")]
pub install_instructions_windows: Option<String>,
#[serde(default, rename = "installInstructionsLinux")]
pub install_instructions_linux: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum AuthMethod {
ApiKey {
env_var: String,
},
OAuth {
auth_url: String,
token_url: String,
},
CliLogin {
command: String,
},
None,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct AgentCapabilities {
#[serde(default, rename = "fileContext")]
pub file_context: bool,
#[serde(default, rename = "streamingOutput")]
pub streaming_output: bool,
#[serde(default, rename = "costReporting")]
pub cost_reporting: bool,
#[serde(default)]
pub cancellation: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentAuthConfig {
pub credentials: Option<String>,
pub token: Option<String>,
#[serde(rename = "expiresAt")]
pub expires_at: Option<DateTime<Utc>>,
}
pub fn validate_backend_definition(definition: &BackendDefinition) -> Vec<String> {
let mut missing = Vec::new();
if definition.agent_type.trim().is_empty() {
missing.push("agent_type".to_string());
}
if definition.cli_command.trim().is_empty() {
missing.push("cli_command".to_string());
}
if definition.install_check_command.trim().is_empty() {
missing.push("install_check_command".to_string());
}
missing
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = CodingAgentsConfig::default();
assert!(!config.enabled);
assert_eq!(config.max_concurrent_tasks, 3);
assert_eq!(config.default_timeout_secs, 1800);
assert_eq!(config.progress_interval_secs, 30);
assert!(config.agents.is_empty());
assert!(config.backends.is_empty());
}
#[test]
fn test_validate_backend_definition_valid() {
let def = BackendDefinition {
agent_type: "claude-code".to_string(),
display_name: "Claude Code".to_string(),
cli_command: "claude".to_string(),
install_check_command: "claude --version".to_string(),
auth_method: AuthMethod::ApiKey {
env_var: "ANTHROPIC_API_KEY".to_string(),
},
capabilities: AgentCapabilities::default(),
install_instructions: "npm install -g @anthropic/claude-code".to_string(),
install_instructions_windows: None,
install_instructions_linux: None,
};
let missing = validate_backend_definition(&def);
assert!(missing.is_empty());
}
#[test]
fn test_validate_backend_definition_missing_agent_type() {
let def = BackendDefinition {
agent_type: "".to_string(),
display_name: "Claude Code".to_string(),
cli_command: "claude".to_string(),
install_check_command: "claude --version".to_string(),
auth_method: AuthMethod::None,
capabilities: AgentCapabilities::default(),
install_instructions: "".to_string(),
install_instructions_windows: None,
install_instructions_linux: None,
};
let missing = validate_backend_definition(&def);
assert_eq!(missing, vec!["agent_type"]);
}
#[test]
fn test_validate_backend_definition_missing_multiple() {
let def = BackendDefinition {
agent_type: " ".to_string(),
display_name: "Test".to_string(),
cli_command: "".to_string(),
install_check_command: " ".to_string(),
auth_method: AuthMethod::None,
capabilities: AgentCapabilities::default(),
install_instructions: "".to_string(),
install_instructions_windows: None,
install_instructions_linux: None,
};
let missing = validate_backend_definition(&def);
assert_eq!(missing.len(), 3);
assert!(missing.contains(&"agent_type".to_string()));
assert!(missing.contains(&"cli_command".to_string()));
assert!(missing.contains(&"install_check_command".to_string()));
}
#[test]
fn test_auth_method_serialization() {
let api_key = AuthMethod::ApiKey {
env_var: "MY_KEY".to_string(),
};
let json = serde_json::to_string(&api_key).unwrap();
assert!(json.contains("\"type\":\"apiKey\""));
assert!(json.contains("\"env_var\":\"MY_KEY\""));
let none = AuthMethod::None;
let json = serde_json::to_string(&none).unwrap();
assert!(json.contains("\"type\":\"none\""));
}
#[test]
fn test_auth_method_deserialization() {
let json = r#"{"type":"apiKey","env_var":"ANTHROPIC_API_KEY"}"#;
let method: AuthMethod = serde_json::from_str(json).unwrap();
match method {
AuthMethod::ApiKey { env_var } => assert_eq!(env_var, "ANTHROPIC_API_KEY"),
_ => panic!("Expected ApiKey variant"),
}
let json = r#"{"type":"oAuth","auth_url":"https://auth.example.com","token_url":"https://token.example.com"}"#;
let method: AuthMethod = serde_json::from_str(json).unwrap();
match method {
AuthMethod::OAuth {
auth_url,
token_url,
} => {
assert_eq!(auth_url, "https://auth.example.com");
assert_eq!(token_url, "https://token.example.com");
}
_ => panic!("Expected OAuth variant"),
}
let json = r#"{"type":"cliLogin","command":"claude login"}"#;
let method: AuthMethod = serde_json::from_str(json).unwrap();
match method {
AuthMethod::CliLogin { command } => assert_eq!(command, "claude login"),
_ => panic!("Expected CliLogin variant"),
}
let json = r#"{"type":"none"}"#;
let method: AuthMethod = serde_json::from_str(json).unwrap();
assert!(matches!(method, AuthMethod::None));
}
#[test]
fn test_agent_capabilities_defaults() {
let caps = AgentCapabilities::default();
assert!(!caps.file_context);
assert!(!caps.streaming_output);
assert!(!caps.cost_reporting);
assert!(!caps.cancellation);
}
#[test]
fn test_coding_agents_config_serde_roundtrip() {
let config = CodingAgentsConfig {
enabled: true,
max_concurrent_tasks: 5,
default_timeout_secs: 3600,
progress_interval_secs: 60,
agents: vec![CodingAgentInstanceConfig {
id: "test-agent".to_string(),
backend_type: "claude-code".to_string(),
endpoint: "http://localhost:8080".to_string(),
transport: None,
workspaces: vec![PathBuf::from("/home/user/projects")],
timeout_secs: Some(900),
cost_cap_usd: Some(5.0),
monthly_budget_usd: Some(100.0),
alias: Some("cc".to_string()),
auth: Some(AgentAuthConfig {
credentials: Some("sk-test-key".to_string()),
token: None,
expires_at: None,
}),
}],
backends: vec![],
};
let json = serde_json::to_string(&config).unwrap();
let deserialized: CodingAgentsConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.enabled, config.enabled);
assert_eq!(deserialized.max_concurrent_tasks, config.max_concurrent_tasks);
assert_eq!(deserialized.default_timeout_secs, config.default_timeout_secs);
assert_eq!(deserialized.progress_interval_secs, config.progress_interval_secs);
assert_eq!(deserialized.agents.len(), 1);
assert_eq!(deserialized.agents[0].id, "test-agent");
assert_eq!(deserialized.agents[0].alias, Some("cc".to_string()));
}
#[test]
fn test_config_deserialize_with_defaults() {
let json = r#"{}"#;
let config: CodingAgentsConfig = serde_json::from_str(json).unwrap();
assert!(!config.enabled);
assert_eq!(config.max_concurrent_tasks, 3);
assert_eq!(config.default_timeout_secs, 1800);
assert_eq!(config.progress_interval_secs, 30);
}
}