use anyhow::{Context, Result};
use std::fs;
use std::path::Path;
use super::Config;
fn merge_toml_values(base: &toml::Value, override_: &toml::Value) -> toml::Value {
match (base, override_) {
(toml::Value::Table(base_table), toml::Value::Table(override_table)) => {
let mut result = base_table.clone();
for (key, value) in override_table {
if let Some(base_value) = result.get(key) {
result.insert(key.clone(), merge_toml_values(base_value, value));
} else {
result.insert(key.clone(), value.clone());
}
}
toml::Value::Table(result)
}
(toml::Value::Array(base_arr), toml::Value::Array(override_arr))
if is_array_of_tables(base_arr) || is_array_of_tables(override_arr) =>
{
let mut result = base_arr.clone();
result.extend(override_arr.iter().cloned());
dedup_tables_by_name(result)
}
(_, override_) => override_.clone(),
}
}
fn is_array_of_tables(arr: &[toml::Value]) -> bool {
!arr.is_empty() && arr.iter().all(|v| v.is_table())
}
fn dedup_tables_by_name(arr: Vec<toml::Value>) -> toml::Value {
let mut seen = std::collections::HashMap::new();
let mut result = Vec::new();
for (i, item) in arr.iter().enumerate() {
if let Some(name) = item
.as_table()
.and_then(|t| t.get("name"))
.and_then(|v| v.as_str())
{
seen.insert(name.to_string(), i);
}
}
for (i, item) in arr.into_iter().enumerate() {
let name = item
.as_table()
.and_then(|t| t.get("name"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
match name {
Some(n) if seen.get(&n) == Some(&i) => result.push(item),
Some(_) => {} None => result.push(item),
}
}
toml::Value::Array(result)
}
fn load_and_merge_toml_from_directory(dir: &Path) -> Result<toml::Value> {
let mut merged: Option<toml::Value> = None;
let mut files: Vec<_> = fs::read_dir(dir)?
.filter_map(|entry| entry.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().map(|e| e == "toml").unwrap_or(false))
.collect();
files.sort();
for file in &files {
let content = fs::read_to_string(file)
.context(format!("Failed to read TOML file: {}", file.display()))?;
let value: toml::Value = toml::from_str(&content)
.context(format!("Failed to parse TOML file: {}", file.display()))?;
merged = Some(if let Some(base) = merged {
merge_toml_values(&base, &value)
} else {
value
});
}
merged.ok_or_else(|| {
anyhow::anyhow!("No TOML files found in config directory: {}", dir.display())
})
}
impl Config {
fn initialize_config(&mut self) {}
pub fn ensure_octomind_dir() -> Result<std::path::PathBuf> {
crate::directories::get_octomind_data_dir()
}
pub fn copy_default_config_template(config_path: &std::path::Path) -> Result<()> {
const DEFAULT_CONFIG_TEMPLATE: &str = include_str!("../../config-templates/default.toml");
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).context(format!(
"Failed to create config directory: {}",
parent.display()
))?;
}
fs::write(config_path, DEFAULT_CONFIG_TEMPLATE).context(format!(
"Failed to write default config template to {}",
config_path.display()
))?;
println!("Created default configuration at {}", config_path.display());
println!("Please edit the configuration file to set your API keys and preferences.");
Ok(())
}
pub fn create_default_config() -> Result<std::path::PathBuf> {
let config_path = crate::directories::get_config_file_path()?;
if !config_path.exists() {
Self::copy_default_config_template(&config_path)?;
}
Ok(config_path)
}
fn inject_default_config() -> Result<Self> {
const DEFAULT_CONFIG_TEMPLATE: &str = include_str!("../../config-templates/default.toml");
let mut config: Config = toml::from_str(DEFAULT_CONFIG_TEMPLATE)
.context("Failed to parse default configuration template")?;
config.build_role_map();
Ok(config)
}
pub fn load() -> Result<Self> {
let config_path = if let Ok(custom_path) = std::env::var("OCTOMIND_CONFIG_PATH") {
std::path::PathBuf::from(custom_path)
} else {
crate::directories::get_config_file_path()?
};
let config_dir = config_path.parent().unwrap_or(Path::new("."));
if !config_dir.exists() {
let default_config = Self::inject_default_config()?;
default_config.save_to_path(&config_path)?;
return Ok(default_config);
}
if !config_path.exists() {
let has_toml_files = config_dir.read_dir()?.any(|e| {
e.ok()
.map(|f| {
f.file_type()
.map(|t| {
t.is_file()
&& f.path().extension().map(|e| e == "toml").unwrap_or(false)
})
.unwrap_or(false)
})
.unwrap_or(false)
});
if !has_toml_files {
let default_config = Self::inject_default_config()?;
default_config.save_to_path(&config_path)?;
return Ok(default_config);
}
}
if config_path.exists() {
super::migrations::check_and_upgrade_config(&config_path)
.context("Failed to check/upgrade config version")?;
}
let merged_value = load_and_merge_toml_from_directory(config_dir)?;
let mut config: Config = merged_value.try_into().context(
"Failed to parse merged TOML configuration. All required fields must be present.",
)?;
config.config_path = Some(config_path);
config.initialize_config();
config.build_role_map();
config.validate()?;
Ok(config)
}
pub fn save(&self) -> Result<()> {
self.validate()?;
let config_path = if let Some(path) = &self.config_path {
path.clone()
} else {
crate::directories::get_config_file_path()?
};
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).context(format!(
"Failed to create config directory: {}",
parent.display()
))?;
}
let config_str =
toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")?;
fs::write(&config_path, config_str).context(format!(
"Failed to write config to {}",
config_path.display()
))?;
println!("Configuration saved to {}", config_path.display());
Ok(())
}
pub fn load_from_path(path: &std::path::Path) -> Result<Self> {
if path.is_dir() {
let merged_value = load_and_merge_toml_from_directory(path)?;
let mut config: Config = merged_value
.try_into()
.context("Failed to parse merged TOML configuration")?;
config.config_path = Some(path.join("config.toml"));
config.initialize_config();
config.build_role_map();
config.validate()?;
Ok(config)
} else {
let config_str = fs::read_to_string(path)
.context(format!("Failed to read config from {}", path.display()))?;
let mut config: Config =
toml::from_str(&config_str).context("Failed to parse TOML configuration")?;
config.config_path = Some(path.to_path_buf());
config.initialize_config();
config.build_role_map();
config.validate()?;
Ok(config)
}
}
pub fn save_to_path(&self, path: &std::path::Path) -> Result<()> {
self.validate()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).context(format!(
"Failed to create config directory: {}",
parent.display()
))?;
}
let config_str =
toml::to_string_pretty(self).context("Failed to serialize configuration to TOML")?;
fs::write(path, config_str)
.context(format!("Failed to write config to {}", path.display()))?;
println!("Configuration saved to {}", path.display());
Ok(())
}
pub fn create_clean_copy_for_saving(&self) -> Self {
self.clone()
}
pub fn update_and_save<F>(&mut self, updater: F) -> Result<()>
where
F: FnOnce(&mut Self),
{
self.validate()?;
let config_path = if let Some(path) = &self.config_path {
path.clone()
} else {
crate::directories::get_config_file_path()?
};
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).context(format!(
"Failed to create config directory: {}",
parent.display()
))?;
}
let clean_config = self.create_clean_copy_for_saving();
let config_str =
toml::to_string(&clean_config).context("Failed to serialize configuration to TOML")?;
fs::write(&config_path, config_str).context(format!(
"Failed to write config to {}",
config_path.display()
))?;
updater(self);
Ok(())
}
pub fn update_specific_field<F>(&mut self, updater: F) -> Result<()>
where
F: Fn(&mut Config),
{
let config_path = if let Some(path) = &self.config_path {
path.clone()
} else {
crate::directories::get_config_file_path()?
};
let mut disk_config = if config_path.exists() {
let config_str = fs::read_to_string(&config_path).context(format!(
"Failed to read config from {}",
config_path.display()
))?;
let mut config: Config =
toml::from_str(&config_str).context("Failed to parse TOML configuration")?;
config.config_path = Some(config_path.clone());
config
} else {
return Err(anyhow::anyhow!(
"No configuration file found at {}. Run with --init to create a default configuration.",
config_path.display()
));
};
updater(&mut disk_config);
disk_config.validate()?;
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent).context(format!(
"Failed to create config directory: {}",
parent.display()
))?;
}
let clean_config = disk_config.create_clean_copy_for_saving();
let config_str =
toml::to_string(&clean_config).context("Failed to serialize configuration to TOML")?;
fs::write(&config_path, config_str).context(format!(
"Failed to write config to {}",
config_path.display()
))?;
updater(self);
Ok(())
}
}
pub fn merge_agent_toml(base: &Config, agent_toml: &str) -> Result<Config> {
let agent_value: toml::Value =
toml::from_str(agent_toml).context("Failed to parse agent manifest TOML")?;
let base_str = toml::to_string(base).context("Failed to serialize base config")?;
let mut base_value: toml::Value =
toml::from_str(&base_str).context("Failed to re-parse base config")?;
if let (Some(toml::Value::Table(base_mcp)), Some(toml::Value::Table(agent_mcp))) =
(base_value.get_mut("mcp"), agent_value.get("mcp"))
{
if let (Some(toml::Value::Array(base_servers)), Some(toml::Value::Array(agent_servers))) =
(base_mcp.get_mut("servers"), agent_mcp.get("servers"))
{
let existing_names: std::collections::HashSet<String> = base_servers
.iter()
.filter_map(|s| {
s.get("name")
.and_then(|n| n.as_str())
.map(|n| n.to_string())
})
.collect();
for server in agent_servers {
let name = server.get("name").and_then(|n| n.as_str()).unwrap_or("");
if !existing_names.contains(name) {
base_servers.push(server.clone());
}
}
}
}
if let (Some(toml::Value::Array(base_roles)), Some(toml::Value::Array(agent_roles))) =
(base_value.get_mut("roles"), agent_value.get("roles"))
{
let existing_names: std::collections::HashSet<String> = base_roles
.iter()
.filter_map(|r| {
r.get("name")
.and_then(|n| n.as_str())
.map(|n| n.to_string())
})
.collect();
for role in agent_roles {
let name = role.get("name").and_then(|n| n.as_str()).unwrap_or("");
if !existing_names.contains(name) {
base_roles.push(role.clone());
}
}
}
if let (Some(toml::Value::Array(base_pipelines)), Some(toml::Value::Array(agent_pipelines))) = (
base_value.get_mut("pipelines"),
agent_value.get("pipelines"),
) {
let existing_names: std::collections::HashSet<String> = base_pipelines
.iter()
.filter_map(|p| {
p.get("name")
.and_then(|n| n.as_str())
.map(|n| n.to_string())
})
.collect();
for pipeline in agent_pipelines {
let name = pipeline.get("name").and_then(|n| n.as_str()).unwrap_or("");
if !existing_names.contains(name) {
base_pipelines.push(pipeline.clone());
}
}
}
if let toml::Value::Table(agent_table) = &agent_value {
if let toml::Value::Table(base_table) = &mut base_value {
for (key, value) in agent_table {
if key == "mcp" || key == "roles" || key == "pipelines" {
continue;
}
if let Some(base_val) = base_table.get(key) {
let merged = merge_toml_values(base_val, value);
base_table.insert(key.clone(), merged);
} else {
base_table.insert(key.clone(), value.clone());
}
}
}
}
let mut merged: Config = base_value
.try_into()
.context("Failed to deserialize merged agent config")?;
merged.build_role_map();
Ok(merged)
}
#[cfg(test)]
mod tests {
use super::*;
fn get_test_config_with_custom_role() -> String {
let template_content = include_str!("../../config-templates/default.toml");
let mut config = template_content.to_string();
config.push_str(
r#"
# Test roles for unit testing
[[roles]]
name = "developer"
temperature = 0.3
top_p = 0.7
top_k = 20
system = "You are a developer assistant."
welcome = "Hello! Developer role."
mcp = { server_refs = [], allowed_tools = [] }
[[roles]]
name = "assistant"
temperature = 0.5
top_p = 0.9
top_k = 40
system = "You are a general assistant."
welcome = "Hello! Assistant role."
mcp = { server_refs = [], allowed_tools = [] }
[[roles]]
name = "tester"
temperature = 0.7
top_p = 0.9
top_k = 50
system = "You are a test assistant."
welcome = "Hello! Test tester role."
mcp = { server_refs = ["test_server", "clt"], allowed_tools = [] }
# Additional test MCP servers for tester role
[[mcp.servers]]
name = "test_server"
type = "stdio"
command = "test_command"
args = ["mcp"]
timeout_seconds = 30
tools = []
[[mcp.servers]]
name = "clt"
type = "stdio"
command = "clt"
args = ["mcp"]
timeout_seconds = 30
tools = []
"#,
);
config
}
#[test]
fn test_role_parsing() {
let test_config = get_test_config_with_custom_role();
let mut config: Config = toml::from_str(&test_config).expect("Failed to parse test config");
config.build_role_map();
assert_eq!(config.roles.len(), 4);
assert_eq!(config.role_map.len(), 3);
assert!(config.role_map.contains_key("tester"));
let tester_role = config.role_map.get("tester").unwrap();
assert_eq!(tester_role.mcp.server_refs, vec!["test_server", "clt"]);
assert!(tester_role.workflow.is_none());
let (role_config, mcp_config, _, _, _) = config.get_role_config("tester");
assert_eq!(role_config.temperature, 0.7);
assert_eq!(mcp_config.server_refs, vec!["test_server", "clt"]);
let merged_config = config.get_merged_config_for_role("tester");
let server_names: Vec<&str> = merged_config.mcp.servers.iter().map(|s| s.name()).collect();
assert!(server_names.contains(&"test_server"));
assert!(server_names.contains(&"clt"));
assert!(!server_names.contains(&"core"));
assert!(!server_names.contains(&"filesystem"));
}
#[test]
fn test_role_merged_config() {
let test_config = get_test_config_with_custom_role();
let mut config: Config = toml::from_str(&test_config).expect("Failed to parse test config");
config.build_role_map();
let merged_config = config.get_merged_config_for_role("tester");
let server_names: Vec<&str> = merged_config.mcp.servers.iter().map(|s| s.name()).collect();
assert!(server_names.contains(&"test_server"));
assert!(server_names.contains(&"clt"));
assert!(!server_names.contains(&"core")); assert!(!server_names.contains(&"filesystem")); }
#[test]
fn test_max_tokens_inheritance() {
let test_config = get_test_config_with_custom_role();
let mut config: Config = toml::from_str(&test_config).expect("Failed to parse test config");
config.build_role_map();
assert_eq!(config.get_max_tokens("developer"), 16384);
assert_eq!(config.get_max_tokens("assistant"), 16384);
assert_eq!(config.get_max_tokens("tester"), 16384);
assert_eq!(config.get_max_tokens("nonexistent_role"), 16384); assert_eq!(config.get_effective_max_tokens(), 16384);
let (role_config, _, _, _, _) = config.get_role_config("tester");
assert_eq!(role_config.temperature, 0.7);
assert!(config.role_map.contains_key("developer"));
}
}