use crate::application::cli::config::battalion_config::BattalionYamlConfig;
use crate::application::cli::config::paladin_config::{
ArsenalConfig, GarrisonConfig, PaladinYamlConfig, Validate,
};
use crate::application::cli::error::CliError;
use crate::application::services::arsenal::arsenal_execution_service::ArsenalExecutionService;
use crate::application::services::arsenal::arsenal_registry_service::ArsenalRegistryService;
use crate::core::platform::container::garrison::{
EvictionStrategy, GarrisonConfig as CoreGarrisonConfig,
};
use crate::infrastructure::adapters::arsenal::mcp_protocol::MCPClient;
use crate::infrastructure::adapters::arsenal::mcp_sse_adapter::MCPSseAdapter;
use crate::infrastructure::adapters::arsenal::mcp_stdio_adapter::MCPStdioAdapter;
use crate::infrastructure::adapters::garrison::in_memory_garrison::InMemoryGarrison;
use crate::infrastructure::adapters::garrison::sqlite_garrison::SqliteGarrison;
use paladin_ports::output::arsenal_port::{ArsenalPort, ArsenalRegistry};
use paladin_ports::output::garrison_port::GarrisonPort;
use std::fs;
use std::path::Path;
use std::sync::Arc;
pub fn load_paladin_config(path: &Path) -> Result<PaladinYamlConfig, CliError> {
if !path.exists() {
return Err(CliError::ConfigFileNotFound {
path: path.to_path_buf(),
});
}
let contents = fs::read_to_string(path).map_err(|e| CliError::IoError {
message: format!("Failed to read config file: {}", path.display()),
source: e,
})?;
let config: PaladinYamlConfig =
serde_yaml::from_str(&contents).map_err(|e| CliError::InvalidYaml {
path: path.to_path_buf(),
source: e,
})?;
config.validate()?;
Ok(config)
}
pub fn load_battalion_config(path: &Path) -> Result<BattalionYamlConfig, CliError> {
if !path.exists() {
return Err(CliError::ConfigFileNotFound {
path: path.to_path_buf(),
});
}
let contents = fs::read_to_string(path).map_err(|e| CliError::IoError {
message: format!("Failed to read config file: {}", path.display()),
source: e,
})?;
let config: BattalionYamlConfig =
serde_yaml::from_str(&contents).map_err(|e| CliError::InvalidYaml {
path: path.to_path_buf(),
source: e,
})?;
config.validate()?;
Ok(config)
}
pub async fn instantiate_garrison(
config: &Option<GarrisonConfig>,
paladin_name: &str,
) -> Result<Option<Arc<dyn GarrisonPort>>, CliError> {
let Some(garrison_config) = config else {
return Ok(None);
};
if garrison_config.garrison_type != "in_memory" && garrison_config.garrison_type != "sqlite" {
return Err(CliError::GarrisonConfigError {
message: format!(
"garrison.type must be 'in_memory' or 'sqlite', got: '{}'",
garrison_config.garrison_type
),
});
}
let max_entries = garrison_config
.config
.as_ref()
.and_then(|c| c.max_entries)
.unwrap_or(100);
let core_config = CoreGarrisonConfig {
max_entries,
max_tokens: Some(4000),
eviction_strategy: EvictionStrategy::ImportanceBased,
preserve_recent_count: 10,
};
match garrison_config.garrison_type.as_str() {
"in_memory" => {
let garrison = InMemoryGarrison::new(core_config);
Ok(Some(Arc::new(garrison) as Arc<dyn GarrisonPort>))
}
"sqlite" => {
let path = garrison_config
.config
.as_ref()
.and_then(|c| c.path.as_ref())
.ok_or_else(|| CliError::GarrisonConfigError {
message: "garrison.config.path is required for type: sqlite".to_string(),
})?;
if let Some(parent) = Path::new(path).parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent).map_err(|e| CliError::GarrisonConfigError {
message: format!(
"garrison.config.path parent directory does not exist and could not be created: {} - {}",
parent.display(),
e
),
})?;
}
let garrison = SqliteGarrison::connect(path, core_config, paladin_name)
.await
.map_err(|e| CliError::GarrisonConfigError {
message: format!("Failed to connect to SQLite garrison at '{}': {}", path, e),
})?;
Ok(Some(Arc::new(garrison) as Arc<dyn GarrisonPort>))
}
_ => unreachable!("Garrison type already validated"),
}
}
pub async fn instantiate_arsenal(
config: &Option<ArsenalConfig>,
) -> Result<Option<Arc<dyn ArsenalPort>>, CliError> {
let Some(arsenal_config) = config else {
return Ok(None);
};
let registry = ArsenalRegistryService::new();
if arsenal_config.mcp_servers.is_empty() {
let service = ArsenalExecutionService::new(Arc::new(registry));
return Ok(Some(Arc::new(service) as Arc<dyn ArsenalPort>));
}
for server_config in &arsenal_config.mcp_servers {
if server_config.server_type != "stdio" && server_config.server_type != "sse" {
return Err(CliError::ArsenalConfigError {
message: format!(
"arsenal.mcp_servers[{}].type must be 'stdio' or 'sse', got: '{}'",
server_config.name, server_config.server_type
),
});
}
match server_config.server_type.as_str() {
"stdio" => {
let command =
server_config
.command
.as_ref()
.ok_or_else(|| CliError::ArsenalConfigError {
message: format!(
"arsenal.mcp_servers[{}].command is required for stdio type",
server_config.name
),
})?;
let args = server_config.args.clone().unwrap_or_default();
let mut adapter = MCPStdioAdapter::new(command, args);
adapter
.connect()
.await
.map_err(|e| CliError::ArsenalConfigError {
message: format!(
"Failed to connect to STDIO MCP server '{}': {}",
server_config.name, e
),
})?;
let client = MCPClient::new(Box::new(adapter));
let tools =
client
.discover_tools()
.await
.map_err(|e| CliError::ArsenalConfigError {
message: format!(
"Failed to discover tools from MCP server '{}': {}",
server_config.name, e
),
})?;
for tool in tools {
registry.register(tool).await;
}
}
"sse" => {
let endpoint = server_config.endpoint.as_ref().ok_or_else(|| {
CliError::ArsenalConfigError {
message: format!(
"arsenal.mcp_servers[{}].endpoint is required for sse type",
server_config.name
),
}
})?;
if !endpoint.starts_with("http://") && !endpoint.starts_with("https://") {
return Err(CliError::ArsenalConfigError {
message: format!(
"arsenal.mcp_servers[{}].endpoint must start with 'http://' or 'https://', got: '{}'",
server_config.name, endpoint
),
});
}
let mut adapter = MCPSseAdapter::new(endpoint);
adapter
.connect()
.await
.map_err(|e| CliError::ArsenalConfigError {
message: format!(
"Failed to connect to SSE MCP server '{}': {}",
server_config.name, e
),
})?;
let client = MCPClient::new(Box::new(adapter));
let tools =
client
.discover_tools()
.await
.map_err(|e| CliError::ArsenalConfigError {
message: format!(
"Failed to discover tools from MCP server '{}': {}",
server_config.name, e
),
})?;
for tool in tools {
registry.register(tool).await;
}
}
_ => unreachable!("Server type already validated"),
}
}
let service = ArsenalExecutionService::new(Arc::new(registry));
Ok(Some(Arc::new(service) as Arc<dyn ArsenalPort>))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_load_valid_paladin_config() {
let yaml = r#"
name: test-paladin
system_prompt: "You are a helpful assistant"
model: gpt-4
temperature: 0.7
max_loops: 3
timeout_seconds: 300
stop_words: []
provider:
type: openai
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
file.flush().unwrap();
let config = load_paladin_config(file.path()).unwrap();
assert_eq!(config.name, "test-paladin");
assert_eq!(config.model, "gpt-4");
assert_eq!(config.provider.provider_type, "openai");
}
#[test]
fn test_load_paladin_config_file_not_found() {
let result = load_paladin_config(Path::new("/nonexistent/file.yaml"));
assert!(matches!(result, Err(CliError::ConfigFileNotFound { .. })));
}
#[test]
fn test_load_paladin_config_invalid_yaml() {
let yaml = r#"
name: test
invalid yaml syntax: [unclosed
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
file.flush().unwrap();
let result = load_paladin_config(file.path());
assert!(matches!(result, Err(CliError::InvalidYaml { .. })));
}
#[test]
fn test_load_paladin_config_missing_required_field() {
let yaml = r#"
name: test
# Missing system_prompt
model: gpt-4
provider:
type: openai
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
file.flush().unwrap();
let result = load_paladin_config(file.path());
assert!(matches!(result, Err(CliError::InvalidYaml { .. })));
}
#[test]
fn test_load_paladin_config_validation_error() {
let yaml = r#"
name: ""
system_prompt: "test"
model: gpt-4
provider:
type: openai
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
file.flush().unwrap();
let result = load_paladin_config(file.path());
assert!(matches!(result, Err(CliError::MissingRequiredField { .. })));
}
#[test]
fn test_load_valid_formation_config() {
let yaml = r#"
type: formation
name: test-formation
pass_output_to_next: true
paladins:
- file: paladin1.yaml
- file: paladin2.yaml
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
file.flush().unwrap();
let config = load_battalion_config(file.path()).unwrap();
assert_eq!(config.battalion_type(), "formation");
}
#[test]
fn test_load_paladin_config_with_vision_fields() {
use std::io::Write;
let mut img_file = NamedTempFile::new().unwrap();
img_file.write_all(b"fake image").unwrap();
let img_path_temp = img_file.path().to_str().unwrap().to_string();
let img_path = format!("{}.png", img_path_temp);
std::fs::copy(&img_path_temp, &img_path).unwrap();
let mut doc_file = NamedTempFile::new().unwrap();
doc_file.write_all(b"fake pdf").unwrap();
let doc_path_temp = doc_file.path().to_str().unwrap().to_string();
let doc_path = format!("{}.pdf", doc_path_temp);
std::fs::copy(&doc_path_temp, &doc_path).unwrap();
let yaml = format!(
r#"
name: vision-paladin
system_prompt: "You are a vision-capable assistant"
model: gpt-4
temperature: 0.7
max_loops: 3
timeout_seconds: 300
vision_enabled: true
images:
- {}
documents:
- {}
provider:
type: openai
"#,
img_path, doc_path
);
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
file.flush().unwrap();
let config = load_paladin_config(file.path()).unwrap();
assert_eq!(config.name, "vision-paladin");
assert!(config.vision_enabled);
assert_eq!(config.images.len(), 1);
assert_eq!(config.documents.len(), 1);
std::fs::remove_file(&img_path).ok();
std::fs::remove_file(&doc_path).ok();
}
#[test]
fn test_load_paladin_config_vision_enabled_without_files() {
let yaml = r#"
name: test-paladin
system_prompt: "You are a helpful assistant"
model: gpt-4
temperature: 0.7
max_loops: 3
timeout_seconds: 300
vision_enabled: true
images: []
documents: []
provider:
type: openai
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(yaml.as_bytes()).unwrap();
file.flush().unwrap();
let result = load_paladin_config(file.path());
assert!(matches!(
result,
Err(CliError::InvalidFieldValue { field, .. }) if field == "vision_enabled"
));
}
}