Files
owlen/crates/owlen-core/src/config.rs

2267 lines
73 KiB
Rust

use crate::Error;
use crate::ProviderConfig;
use crate::Result;
use crate::mode::ModeConfig;
use crate::ui::RoleLabelDisplay;
use serde::de::{self, Deserializer, Visitor};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::Duration;
/// Default location for the OWLEN configuration file
pub const DEFAULT_CONFIG_PATH: &str = "~/.config/owlen/config.toml";
/// Current schema version written to `config.toml`.
pub const CONFIG_SCHEMA_VERSION: &str = "1.6.0";
/// Provider config key for forcing Ollama provider mode.
pub const OLLAMA_MODE_KEY: &str = "ollama_mode";
/// Extra config key storing the preferred Ollama Cloud endpoint.
pub const OLLAMA_CLOUD_ENDPOINT_KEY: &str = "cloud_endpoint";
/// Canonical Ollama Cloud base URL.
pub const OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com";
/// Environment variable used for Ollama Cloud authentication.
pub const OLLAMA_CLOUD_API_KEY_ENV: &str = "OLLAMA_CLOUD_API_KEY";
/// Default base URL for local Ollama daemons.
pub const OLLAMA_LOCAL_BASE_URL: &str = "http://localhost:11434";
/// Default OpenAI API base URL.
pub const OPENAI_DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
/// Environment variable name used for OpenAI API keys.
pub const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY";
/// Default Anthropic API base URL.
pub const ANTHROPIC_DEFAULT_BASE_URL: &str = "https://api.anthropic.com/v1";
/// Environment variable name used for Anthropic API keys.
pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
/// Core configuration shared by all OWLEN clients
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
/// Schema version for on-disk configuration files
#[serde(default = "Config::default_schema_version")]
pub schema_version: String,
/// General application settings
pub general: GeneralSettings,
/// MCP (Multi-Client-Provider) settings
#[serde(default)]
pub mcp: McpSettings,
/// Provider specific configuration keyed by provider name
#[serde(default)]
pub providers: HashMap<String, ProviderConfig>,
/// UI preferences that frontends can opt into
#[serde(default)]
pub ui: UiSettings,
/// Storage related options
#[serde(default)]
pub storage: StorageSettings,
/// Input handling preferences
#[serde(default)]
pub input: InputSettings,
/// Privacy controls for tooling and network usage
#[serde(default)]
pub privacy: PrivacySettings,
/// Security controls for sandboxing and resource limits
#[serde(default)]
pub security: SecuritySettings,
/// Per-tool configuration toggles
#[serde(default)]
pub tools: ToolSettings,
/// Mode-specific tool availability configuration
#[serde(default)]
pub modes: ModeConfig,
/// External MCP server definitions
#[serde(default)]
pub mcp_servers: Vec<McpServerConfig>,
/// User-scoped resource definitions
#[serde(default)]
pub mcp_resources: Vec<McpResourceConfig>,
/// Resolved MCP servers across scopes (runtime only).
#[serde(skip)]
pub scoped_mcp_servers: Vec<ScopedMcpServer>,
/// Effective MCP servers after applying precedence rules (runtime only).
#[serde(skip)]
pub effective_mcp_servers: Vec<McpServerConfig>,
/// Resolved MCP resources across scopes (runtime only).
#[serde(skip)]
pub scoped_mcp_resources: Vec<ScopedMcpResource>,
/// Effective MCP resources after precedence (runtime only).
#[serde(skip)]
pub effective_mcp_resources: Vec<McpResourceConfig>,
}
impl Default for Config {
fn default() -> Self {
let providers = default_provider_configs();
Self {
schema_version: Self::default_schema_version(),
general: GeneralSettings::default(),
mcp: McpSettings::default(),
providers,
ui: UiSettings::default(),
storage: StorageSettings::default(),
input: InputSettings::default(),
privacy: PrivacySettings::default(),
security: SecuritySettings::default(),
tools: ToolSettings::default(),
modes: ModeConfig::default(),
mcp_servers: Vec::new(),
mcp_resources: Vec::new(),
scoped_mcp_servers: Vec::new(),
effective_mcp_servers: Vec::new(),
scoped_mcp_resources: Vec::new(),
effective_mcp_resources: Vec::new(),
}
}
}
/// Configuration for an external MCP server process.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpServerConfig {
/// Logical name used to reference the server (e.g., "web_search").
pub name: String,
/// Command to execute (binary or script).
pub command: String,
/// Arguments passed to the command.
#[serde(default)]
pub args: Vec<String>,
/// Transport mechanism, currently only "stdio" is supported.
#[serde(default = "McpServerConfig::default_transport")]
pub transport: String,
/// Optional environment variable map for the process.
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
/// Optional OAuth configuration for remote servers.
#[serde(default)]
pub oauth: Option<McpOAuthConfig>,
}
impl McpServerConfig {
fn default_transport() -> String {
"stdio".to_string()
}
}
/// OAuth configuration for MCP servers that require delegated authentication.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpOAuthConfig {
/// Public client identifier registered with the authorization server.
pub client_id: String,
/// Optional client secret for confidential clients.
#[serde(default)]
pub client_secret: Option<String>,
/// OAuth authorization endpoint (used for web-based flows).
pub authorize_url: String,
/// OAuth token endpoint.
pub token_url: String,
/// Optional device authorization endpoint for device-code flows.
#[serde(default)]
pub device_authorization_url: Option<String>,
/// Optional redirect URL (PKCE / authorization-code flows).
#[serde(default)]
pub redirect_url: Option<String>,
/// Requested OAuth scopes.
#[serde(default)]
pub scopes: Vec<String>,
/// Environment variable name populated with the bearer access token when spawning stdio servers.
#[serde(default)]
pub token_env: Option<String>,
/// Optional HTTP header name for bearer authentication (defaults to "Authorization").
#[serde(default)]
pub header: Option<String>,
/// Optional prefix prepended to the access token (defaults to "Bearer ").
#[serde(default)]
pub header_prefix: Option<String>,
}
impl McpOAuthConfig {
pub fn header_name(&self) -> &str {
self.header.as_deref().unwrap_or("Authorization")
}
pub fn header_prefix(&self) -> &str {
self.header_prefix.as_deref().unwrap_or("Bearer ")
}
}
/// Scope for MCP server configuration entries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum McpConfigScope {
/// User-level configuration stored under the user's config directory.
User,
/// Project configuration stored in the repository (e.g. `.mcp.json`).
Project,
/// Local overrides stored alongside the project but excluded from version control.
Local,
}
impl McpConfigScope {
fn precedence_iter() -> impl Iterator<Item = Self> {
[
McpConfigScope::Local,
McpConfigScope::Project,
McpConfigScope::User,
]
.into_iter()
}
fn as_str(self) -> &'static str {
match self {
McpConfigScope::User => "user",
McpConfigScope::Project => "project",
McpConfigScope::Local => "local",
}
}
}
impl fmt::Display for McpConfigScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for McpConfigScope {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"user" => Ok(McpConfigScope::User),
"project" => Ok(McpConfigScope::Project),
"local" => Ok(McpConfigScope::Local),
other => Err(format!("Unknown MCP scope '{other}'")),
}
}
}
/// A resolved MCP server entry annotated with its configuration scope.
#[derive(Debug, Clone)]
pub struct ScopedMcpServer {
pub scope: McpConfigScope,
pub config: McpServerConfig,
}
/// Configuration for a predefined MCP resource reference.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct McpResourceConfig {
/// Named MCP server that owns this resource.
pub server: String,
/// URI or path identifying the resource within the server.
pub uri: String,
/// Optional short title displayed in UI.
#[serde(default)]
pub title: Option<String>,
/// Optional detailed description shown in tooltips.
#[serde(default)]
pub description: Option<String>,
}
/// Resource entry annotated with its originating scope.
#[derive(Debug, Clone)]
pub struct ScopedMcpResource {
pub scope: McpConfigScope,
pub config: McpResourceConfig,
}
impl Config {
fn default_schema_version() -> String {
CONFIG_SCHEMA_VERSION.to_string()
}
/// Load configuration from disk, falling back to defaults when missing
pub fn load(path: Option<&Path>) -> Result<Self> {
let path = match path {
Some(path) => path.to_path_buf(),
None => default_config_path(),
};
if path.exists() {
let content = fs::read_to_string(&path)?;
let parsed: toml::Value =
toml::from_str(&content).map_err(|e| crate::Error::Config(e.to_string()))?;
let mut parsed = parsed;
migrate_legacy_provider_tables(&mut parsed);
let previous_version = parsed
.get("schema_version")
.and_then(|value| value.as_str())
.unwrap_or("0.0.0")
.to_string();
if let Some(agent_table) = parsed.get("agent").and_then(|value| value.as_table())
&& agent_table.contains_key("max_tool_calls")
{
log::warn!(
"Configuration option agent.max_tool_calls is deprecated and ignored. \
The agent now uses agent.max_iterations."
);
}
let mut config: Config = parsed
.try_into()
.map_err(|e: toml::de::Error| crate::Error::Config(e.to_string()))?;
config.ensure_defaults();
config.mcp.apply_backward_compat();
config.apply_schema_migrations(&previous_version);
config.expand_provider_env_vars()?;
config.refresh_mcp_servers(None)?;
config.validate()?;
Ok(config)
} else {
let mut config = Config::default();
config.expand_provider_env_vars()?;
config.refresh_mcp_servers(None)?;
Ok(config)
}
}
/// Persist configuration to disk
pub fn save(&self, path: Option<&Path>) -> Result<()> {
let mut validator = self.clone();
validator.refresh_mcp_servers(None)?;
validator.validate()?;
let path = match path {
Some(path) => path.to_path_buf(),
None => default_config_path(),
};
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)?;
}
let mut snapshot = self.clone();
snapshot.schema_version = Config::default_schema_version();
let content =
toml::to_string_pretty(&snapshot).map_err(|e| crate::Error::Config(e.to_string()))?;
fs::write(path, content)?;
Ok(())
}
/// Get provider configuration by provider name
pub fn provider(&self, name: &str) -> Option<&ProviderConfig> {
let key = normalize_provider_key(name);
self.providers.get(&key)
}
/// Update or insert a provider configuration
pub fn upsert_provider(&mut self, name: impl Into<String>, config: ProviderConfig) {
let raw = name.into();
let key = normalize_provider_key(&raw);
let mut config = config;
if config.provider_type.is_empty() {
config.provider_type = key.clone();
}
self.providers.insert(key, config);
}
/// Resolve default model in order of priority: explicit default, first cached model, provider fallback
pub fn resolve_default_model<'a>(
&'a self,
models: &'a [crate::types::ModelInfo],
) -> Option<&'a str> {
if let Some(model) = self.general.default_model.as_deref()
&& models.iter().any(|m| m.id == model || m.name == model)
{
return Some(model);
}
if let Some(first) = models.first() {
return Some(&first.id);
}
self.general.default_model.as_deref()
}
fn ensure_defaults(&mut self) {
if self.general.default_provider.is_empty() || self.general.default_provider == "ollama" {
self.general.default_provider = "ollama_local".to_string();
}
let mut defaults = default_provider_configs();
for (name, default_cfg) in defaults.drain() {
self.providers.entry(name).or_insert(default_cfg);
}
if self.schema_version.is_empty() {
self.schema_version = Self::default_schema_version();
}
}
fn expand_provider_env_vars(&mut self) -> Result<()> {
for (provider_name, provider) in self.providers.iter_mut() {
expand_provider_entry(provider_name, provider)?;
}
Ok(())
}
/// Refresh the resolved MCP server list by loading scope-specific definitions.
pub fn refresh_mcp_servers(&mut self, project_hint: Option<&Path>) -> Result<()> {
let mut scoped_servers = Vec::new();
let mut scoped_resources = Vec::new();
let mut user_servers = self.mcp_servers.clone();
expand_mcp_servers(&mut user_servers, "config.mcp_servers")?;
for server in user_servers {
scoped_servers.push(ScopedMcpServer {
scope: McpConfigScope::User,
config: server,
});
}
let mut user_resources = self.mcp_resources.clone();
expand_mcp_resources(&mut user_resources, "config.mcp_resources")?;
for resource in user_resources {
scoped_resources.push(ScopedMcpResource {
scope: McpConfigScope::User,
config: resource,
});
}
for scope in [McpConfigScope::Project, McpConfigScope::Local] {
if let Some(path) = mcp_scope_path(scope, project_hint) {
let mut file = read_scope_config(&path)?;
let server_context = format!("mcp.{scope}.servers");
expand_mcp_servers(&mut file.servers, &server_context)?;
for server in file.servers {
scoped_servers.push(ScopedMcpServer {
scope,
config: server,
});
}
let resource_context = format!("mcp.{scope}.resources");
expand_mcp_resources(&mut file.resources, &resource_context)?;
for resource in file.resources {
scoped_resources.push(ScopedMcpResource {
scope,
config: resource,
});
}
}
}
let mut effective_servers = Vec::new();
let mut seen_servers = HashSet::new();
for scope in McpConfigScope::precedence_iter() {
for entry in scoped_servers.iter().filter(|entry| entry.scope == scope) {
if seen_servers.insert(entry.config.name.clone()) {
effective_servers.push(entry.config.clone());
}
}
}
let mut effective_resources = Vec::new();
let mut seen_resources: HashSet<(String, String)> = HashSet::new();
for scope in McpConfigScope::precedence_iter() {
for entry in scoped_resources.iter().filter(|entry| entry.scope == scope) {
let key = (entry.config.server.clone(), entry.config.uri.clone());
if seen_resources.insert(key) {
effective_resources.push(entry.config.clone());
}
}
}
self.scoped_mcp_servers = scoped_servers;
self.effective_mcp_servers = effective_servers;
self.scoped_mcp_resources = scoped_resources;
self.effective_mcp_resources = effective_resources;
Ok(())
}
/// Return the merged MCP servers using scope precedence (local > project > user).
pub fn effective_mcp_servers(&self) -> &[McpServerConfig] {
&self.effective_mcp_servers
}
/// Return MCP servers annotated with their originating scope.
pub fn scoped_mcp_servers(&self) -> &[ScopedMcpServer] {
&self.scoped_mcp_servers
}
/// Return merged MCP resources using scope precedence (local > project > user).
pub fn effective_mcp_resources(&self) -> &[McpResourceConfig] {
&self.effective_mcp_resources
}
/// Return scoped MCP resources with their origin scope metadata.
pub fn scoped_mcp_resources(&self) -> &[ScopedMcpResource] {
&self.scoped_mcp_resources
}
/// Locate a configured resource by server and URI.
pub fn find_resource(&self, server: &str, uri: &str) -> Option<&McpResourceConfig> {
self.effective_mcp_resources
.iter()
.find(|resource| resource.server == server && resource.uri == uri)
}
/// Add or replace an MCP server definition within the specified scope.
pub fn add_mcp_server(
&mut self,
scope: McpConfigScope,
server: McpServerConfig,
project_hint: Option<&Path>,
) -> Result<()> {
match scope {
McpConfigScope::User => {
self.mcp_servers
.retain(|existing| existing.name != server.name);
self.mcp_servers.push(server);
}
other => {
let path = mcp_scope_path(other, project_hint).ok_or_else(|| {
Error::Config(format!(
"Unable to resolve project root for MCP scope '{}'",
other
))
})?;
let mut file = read_scope_config(&path)?;
file.servers.retain(|existing| existing.name != server.name);
file.servers.push(server);
write_scope_config(&path, &file)?;
}
}
self.refresh_mcp_servers(project_hint)?;
Ok(())
}
/// Remove an MCP server from the given scope, or infer the scope if omitted.
pub fn remove_mcp_server(
&mut self,
scope: Option<McpConfigScope>,
name: &str,
project_hint: Option<&Path>,
) -> Result<Option<McpConfigScope>> {
let target_scope = if let Some(scope) = scope {
scope
} else {
self.refresh_mcp_servers(project_hint)?;
match self
.scoped_mcp_servers
.iter()
.find(|entry| entry.config.name == name)
{
Some(entry) => entry.scope,
None => return Ok(None),
}
};
let removed = match target_scope {
McpConfigScope::User => {
let before = self.mcp_servers.len();
self.mcp_servers.retain(|entry| entry.name != name);
before != self.mcp_servers.len()
}
other => {
let path = mcp_scope_path(other, project_hint).ok_or_else(|| {
Error::Config(format!(
"Unable to resolve project root for MCP scope '{}'",
other
))
})?;
let mut file = read_scope_config(&path)?;
let before = file.servers.len();
file.servers.retain(|entry| entry.name != name);
if before == file.servers.len() {
false
} else {
write_scope_config(&path, &file)?;
true
}
}
};
if removed {
self.refresh_mcp_servers(project_hint)?;
Ok(Some(target_scope))
} else {
Ok(None)
}
}
/// Validate configuration invariants and surface actionable error messages.
pub fn validate(&self) -> Result<()> {
self.validate_default_provider()?;
self.validate_mcp_settings()?;
self.validate_mcp_servers()?;
self.validate_providers()?;
Ok(())
}
fn apply_schema_migrations(&mut self, previous_version: &str) {
if previous_version != CONFIG_SCHEMA_VERSION {
log::info!(
"Upgrading configuration schema from '{}' to '{}'",
previous_version,
CONFIG_SCHEMA_VERSION
);
}
self.migrate_provider_entries();
if self.general.default_provider == "ollama" {
self.general.default_provider = "ollama_local".to_string();
}
self.ensure_defaults();
self.schema_version = CONFIG_SCHEMA_VERSION.to_string();
}
fn migrate_provider_entries(&mut self) {
let mut migrated = default_provider_configs();
let legacy_entries = std::mem::take(&mut self.providers);
for (original_key, mut legacy) in legacy_entries {
if original_key == "ollama" {
Self::merge_legacy_ollama_provider(legacy, &mut migrated);
continue;
}
let normalized = normalize_provider_key(&original_key);
let entry = migrated
.entry(normalized.clone())
.or_insert_with(|| ProviderConfig {
enabled: true,
provider_type: normalized.clone(),
base_url: None,
api_key: None,
api_key_env: None,
extra: HashMap::new(),
});
if legacy.provider_type.is_empty() {
legacy.provider_type = normalized.clone();
}
entry.merge_from(legacy);
if entry.provider_type.is_empty() {
entry.provider_type = normalized;
}
}
self.providers = migrated;
}
fn merge_legacy_ollama_provider(
mut legacy: ProviderConfig,
targets: &mut HashMap<String, ProviderConfig>,
) {
let mode = legacy
.extra
.remove(OLLAMA_MODE_KEY)
.and_then(|value| value.as_str().map(|s| s.trim().to_ascii_lowercase()));
let api_key_present = legacy
.api_key
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
let cloud_candidate =
matches!(mode.as_deref(), Some("cloud")) || is_cloud_base_url(legacy.base_url.as_ref());
let should_enable_cloud = cloud_candidate || api_key_present;
if matches!(mode.as_deref(), Some("local")) || !should_enable_cloud {
if let Some(local) = targets.get_mut("ollama_local") {
let mut copy = legacy.clone();
copy.api_key = None;
copy.api_key_env = None;
copy.enabled = true;
local.merge_from(copy);
local.enabled = true;
if local.base_url.is_none() {
local.base_url = Some(OLLAMA_LOCAL_BASE_URL.to_string());
}
}
}
if should_enable_cloud || matches!(mode.as_deref(), Some("cloud")) {
if let Some(cloud) = targets.get_mut("ollama_cloud") {
legacy.enabled = true;
cloud.merge_from(legacy);
cloud.enabled = true;
if cloud.base_url.is_none() {
cloud.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
}
if cloud.api_key_env.is_none() {
cloud.api_key_env = Some(OLLAMA_CLOUD_API_KEY_ENV.to_string());
}
}
}
}
fn validate_default_provider(&self) -> Result<()> {
if self.general.default_provider.trim().is_empty() {
return Err(crate::Error::Config(
"general.default_provider must reference a configured provider".to_string(),
));
}
if self.provider(&self.general.default_provider).is_none() {
return Err(crate::Error::Config(format!(
"Default provider '{}' is not defined under [providers]",
self.general.default_provider
)));
}
Ok(())
}
fn validate_mcp_settings(&self) -> Result<()> {
let has_effective_servers = if self.effective_mcp_servers.is_empty() {
!self.mcp_servers.is_empty()
} else {
!self.effective_mcp_servers.is_empty()
};
match self.mcp.mode {
McpMode::RemoteOnly => {
if !has_effective_servers {
return Err(crate::Error::Config(
"[mcp].mode = 'remote_only' requires at least one [[mcp_servers]] entry"
.to_string(),
));
}
}
McpMode::RemotePreferred => {
if !self.mcp.allow_fallback && !has_effective_servers {
return Err(crate::Error::Config(
"[mcp].allow_fallback = false requires at least one [[mcp_servers]] entry"
.to_string(),
));
}
}
McpMode::Disabled => {
return Err(crate::Error::Config(
"[mcp].mode = 'disabled' is not supported by this build of Owlen".to_string(),
));
}
_ => {}
}
Ok(())
}
fn validate_mcp_servers(&self) -> Result<()> {
if self.scoped_mcp_servers.is_empty() {
for server in &self.mcp_servers {
validate_mcp_server_entry(server, McpConfigScope::User)?;
}
} else {
for entry in &self.scoped_mcp_servers {
validate_mcp_server_entry(&entry.config, entry.scope)?;
}
}
Ok(())
}
fn validate_providers(&self) -> Result<()> {
for (name, provider) in &self.providers {
if !provider.enabled {
continue;
}
match name.as_str() {
"ollama_local" => {
if is_blank(&provider.base_url) {
return Err(Error::Config(
"providers.ollama_local.base_url must be set when enabled".into(),
));
}
}
"ollama_cloud" => {
if is_blank(&provider.base_url) {
return Err(Error::Config(
"providers.ollama_cloud.base_url must be set when enabled".into(),
));
}
if is_blank(&provider.api_key) && is_blank(&provider.api_key_env) {
return Err(Error::Config(
"providers.ollama_cloud requires `api_key` or `api_key_env` when enabled"
.into(),
));
}
}
"openai" | "anthropic" => {
if is_blank(&provider.api_key) && is_blank(&provider.api_key_env) {
return Err(Error::Config(format!(
"providers.{name} requires `api_key` or `api_key_env` when enabled"
)));
}
}
_ => {}
}
}
Ok(())
}
}
fn default_provider_configs() -> HashMap<String, ProviderConfig> {
let mut providers = HashMap::new();
for name in ["ollama_local", "ollama_cloud", "openai", "anthropic"] {
if let Some(config) = default_provider_config_for(name) {
providers.insert(name.to_string(), config);
}
}
providers
}
fn default_ollama_local_config() -> ProviderConfig {
ProviderConfig {
enabled: true,
provider_type: canonical_provider_type("ollama_local"),
base_url: Some(OLLAMA_LOCAL_BASE_URL.to_string()),
api_key: None,
api_key_env: None,
extra: HashMap::new(),
}
}
fn default_ollama_cloud_config() -> ProviderConfig {
let mut extra = HashMap::new();
extra.insert(
OLLAMA_CLOUD_ENDPOINT_KEY.to_string(),
serde_json::Value::String(OLLAMA_CLOUD_BASE_URL.to_string()),
);
ProviderConfig {
enabled: false,
provider_type: canonical_provider_type("ollama_cloud"),
base_url: Some(OLLAMA_CLOUD_BASE_URL.to_string()),
api_key: None,
api_key_env: Some(OLLAMA_CLOUD_API_KEY_ENV.to_string()),
extra,
}
}
fn default_openai_config() -> ProviderConfig {
ProviderConfig {
enabled: false,
provider_type: canonical_provider_type("openai"),
base_url: Some(OPENAI_DEFAULT_BASE_URL.to_string()),
api_key: None,
api_key_env: Some(OPENAI_API_KEY_ENV.to_string()),
extra: HashMap::new(),
}
}
fn default_anthropic_config() -> ProviderConfig {
ProviderConfig {
enabled: false,
provider_type: canonical_provider_type("anthropic"),
base_url: Some(ANTHROPIC_DEFAULT_BASE_URL.to_string()),
api_key: None,
api_key_env: Some(ANTHROPIC_API_KEY_ENV.to_string()),
extra: HashMap::new(),
}
}
fn default_provider_config_for(name: &str) -> Option<ProviderConfig> {
match name {
"ollama_local" => Some(default_ollama_local_config()),
"ollama_cloud" => Some(default_ollama_cloud_config()),
"openai" => Some(default_openai_config()),
"anthropic" => Some(default_anthropic_config()),
_ => None,
}
}
fn normalize_provider_key(name: &str) -> String {
let normalized = name.trim().to_ascii_lowercase();
match normalized.as_str() {
"ollama" | "ollama-local" => "ollama_local".to_string(),
"ollama_cloud" | "ollama-cloud" => "ollama_cloud".to_string(),
other => other.replace('-', "_"),
}
}
fn canonical_provider_type(key: &str) -> String {
match key {
"ollama_local" => "ollama".to_string(),
other => other.to_string(),
}
}
fn is_blank(value: &Option<String>) -> bool {
value.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true)
}
fn migrate_legacy_provider_tables(document: &mut toml::Value) {
let Some(table) = document.as_table_mut() else {
return;
};
let mut legacy = Vec::new();
for key in ["ollama", "ollama_cloud", "ollama-cloud"] {
if let Some(entry) = table.remove(key) {
legacy.push((key.to_string(), entry));
}
}
if legacy.is_empty() {
return;
}
let providers_entry = table
.entry("providers".to_string())
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
if let Some(providers_table) = providers_entry.as_table_mut() {
for (key, value) in legacy {
providers_table.insert(key, value);
}
}
}
fn is_cloud_base_url(base_url: Option<&String>) -> bool {
base_url
.map(|url| {
let trimmed = url.trim_end_matches('/');
trimmed == OLLAMA_CLOUD_BASE_URL || trimmed.starts_with("https://ollama.com/")
})
.unwrap_or(false)
}
fn validate_mcp_server_entry(server: &McpServerConfig, scope: McpConfigScope) -> Result<()> {
if server.name.trim().is_empty() {
return Err(Error::Config(format!(
"Each MCP server entry must include a non-empty name (scope: {scope})"
)));
}
if server.command.trim().is_empty() {
return Err(Error::Config(format!(
"MCP server '{}' must define a command or endpoint (scope: {scope})",
server.name
)));
}
let transport = server.transport.to_lowercase();
if !matches!(transport.as_str(), "stdio" | "http" | "websocket") {
return Err(Error::Config(format!(
"Unknown MCP transport '{}' for server '{}' (scope: {scope})",
server.transport, server.name
)));
}
if let Some(oauth) = &server.oauth {
if oauth.client_id.trim().is_empty() {
return Err(Error::Config(format!(
"MCP server '{}' defines OAuth without a client_id",
server.name
)));
}
if oauth.authorize_url.trim().is_empty() {
return Err(Error::Config(format!(
"MCP server '{}' defines OAuth without an authorize_url",
server.name
)));
}
if oauth.token_url.trim().is_empty() {
return Err(Error::Config(format!(
"MCP server '{}' defines OAuth without a token_url",
server.name
)));
}
if oauth.device_authorization_url.is_none() && oauth.redirect_url.is_none() {
return Err(Error::Config(format!(
"MCP server '{}' must define either device_authorization_url or redirect_url for OAuth flows",
server.name
)));
}
}
Ok(())
}
fn expand_provider_entry(provider_name: &str, provider: &mut ProviderConfig) -> Result<()> {
if let Some(ref mut base_url) = provider.base_url {
let expanded = expand_env_string(
base_url.as_str(),
&format!("providers.{provider_name}.base_url"),
)?;
*base_url = expanded;
}
if let Some(ref mut api_key) = provider.api_key {
let expanded = expand_env_string(
api_key.as_str(),
&format!("providers.{provider_name}.api_key"),
)?;
*api_key = expanded;
}
for (extra_key, extra_value) in provider.extra.iter_mut() {
if let serde_json::Value::String(current) = extra_value {
let expanded = expand_env_string(
current.as_str(),
&format!("providers.{provider_name}.{}", extra_key),
)?;
*current = expanded;
}
}
Ok(())
}
fn expand_mcp_servers(servers: &mut [McpServerConfig], field_path: &str) -> Result<()> {
for (idx, server) in servers.iter_mut().enumerate() {
expand_mcp_server_entry(server, field_path, idx)?;
}
Ok(())
}
fn expand_mcp_server_entry(
server: &mut McpServerConfig,
field_path: &str,
index: usize,
) -> Result<()> {
server.command = expand_env_string(
server.command.as_str(),
&format!("{field_path}[{index}].command"),
)?;
for (arg_idx, arg) in server.args.iter_mut().enumerate() {
*arg = expand_env_string(
arg.as_str(),
&format!("{field_path}[{index}].args[{arg_idx}]"),
)?;
}
for (env_key, env_value) in server.env.iter_mut() {
*env_value = expand_env_string(
env_value.as_str(),
&format!("{field_path}[{index}].env.{env_key}"),
)?;
}
if let Some(oauth) = server.oauth.as_mut() {
oauth.client_id = expand_env_string(
oauth.client_id.as_str(),
&format!("{field_path}[{index}].oauth.client_id"),
)?;
oauth.authorize_url = expand_env_string(
oauth.authorize_url.as_str(),
&format!("{field_path}[{index}].oauth.authorize_url"),
)?;
oauth.token_url = expand_env_string(
oauth.token_url.as_str(),
&format!("{field_path}[{index}].oauth.token_url"),
)?;
if let Some(secret) = oauth.client_secret.as_mut() {
*secret = expand_env_string(
secret.as_str(),
&format!("{field_path}[{index}].oauth.client_secret"),
)?;
}
if let Some(device_url) = oauth.device_authorization_url.as_mut() {
*device_url = expand_env_string(
device_url.as_str(),
&format!("{field_path}[{index}].oauth.device_authorization_url"),
)?;
}
if let Some(redirect) = oauth.redirect_url.as_mut() {
*redirect = expand_env_string(
redirect.as_str(),
&format!("{field_path}[{index}].oauth.redirect_url"),
)?;
}
if let Some(token_env) = oauth.token_env.as_mut() {
*token_env = expand_env_string(
token_env.as_str(),
&format!("{field_path}[{index}].oauth.token_env"),
)?;
}
if let Some(header) = oauth.header.as_mut() {
*header = expand_env_string(
header.as_str(),
&format!("{field_path}[{index}].oauth.header"),
)?;
}
if let Some(prefix) = oauth.header_prefix.as_mut() {
*prefix = expand_env_string(
prefix.as_str(),
&format!("{field_path}[{index}].oauth.header_prefix"),
)?;
}
for (scope_idx, scope) in oauth.scopes.iter_mut().enumerate() {
*scope = expand_env_string(
scope.as_str(),
&format!("{field_path}[{index}].oauth.scopes[{scope_idx}]"),
)?;
}
}
Ok(())
}
fn expand_mcp_resources(resources: &mut [McpResourceConfig], field_path: &str) -> Result<()> {
for (idx, resource) in resources.iter_mut().enumerate() {
expand_mcp_resource_entry(resource, field_path, idx)?;
}
Ok(())
}
fn expand_mcp_resource_entry(
resource: &mut McpResourceConfig,
field_path: &str,
index: usize,
) -> Result<()> {
resource.server = expand_env_string(
resource.server.as_str(),
&format!("{field_path}[{index}].server"),
)?;
resource.uri = expand_env_string(resource.uri.as_str(), &format!("{field_path}[{index}].uri"))?;
if let Some(title) = resource.title.as_mut() {
*title = expand_env_string(title.as_str(), &format!("{field_path}[{index}].title"))?;
}
if let Some(description) = resource.description.as_mut() {
*description = expand_env_string(
description.as_str(),
&format!("{field_path}[{index}].description"),
)?;
}
Ok(())
}
fn expand_env_string(input: &str, field_path: &str) -> Result<String> {
if !input.contains('$') {
return Ok(input.to_string());
}
match shellexpand::env(input) {
Ok(expanded) => Ok(expanded.into_owned()),
Err(err) => match err.cause {
std::env::VarError::NotPresent => Err(crate::Error::Config(format!(
"Environment variable {} referenced in {field_path} is not set",
err.var_name
))),
std::env::VarError::NotUnicode(_) => Err(crate::Error::Config(format!(
"Environment variable {} referenced in {field_path} contains invalid Unicode",
err.var_name
))),
},
}
}
/// Default configuration path with user home expansion
pub fn default_config_path() -> PathBuf {
if let Some(config_dir) = dirs::config_dir() {
return config_dir.join("owlen").join("config.toml");
}
PathBuf::from(shellexpand::tilde(DEFAULT_CONFIG_PATH).as_ref())
}
#[derive(Serialize, Deserialize, Default, Clone)]
struct McpConfigFile {
#[serde(default)]
servers: Vec<McpServerConfig>,
#[serde(default)]
resources: Vec<McpResourceConfig>,
}
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum McpConfigEnvelope {
Array(Vec<McpServerConfig>),
Object(McpConfigFile),
}
fn read_scope_config(path: &Path) -> Result<McpConfigFile> {
if !path.exists() {
return Ok(McpConfigFile::default());
}
let contents = fs::read_to_string(path).map_err(Error::Io)?;
if contents.trim().is_empty() {
return Ok(McpConfigFile::default());
}
let doc: McpConfigEnvelope = serde_json::from_str(&contents).map_err(|err| {
Error::Config(format!(
"Failed to parse MCP configuration at {}: {err}",
path.display()
))
})?;
Ok(match doc {
McpConfigEnvelope::Array(servers) => McpConfigFile {
servers,
resources: Vec::new(),
},
McpConfigEnvelope::Object(doc) => doc,
})
}
fn write_scope_config(path: &Path, file: &McpConfigFile) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(Error::Io)?;
}
let serialized = serde_json::to_string_pretty(file).map_err(|err| {
Error::Config(format!(
"Failed to serialize MCP configuration for {}: {err}",
path.display()
))
})?;
fs::write(path, serialized).map_err(Error::Io)
}
/// Resolve the configuration file path for a given scope.
pub fn mcp_scope_path(scope: McpConfigScope, project_hint: Option<&Path>) -> Option<PathBuf> {
match scope {
McpConfigScope::User => dirs::config_dir()
.or_else(|| Some(PathBuf::from(shellexpand::tilde("~/.config").as_ref())))
.map(|dir| dir.join("owlen").join("mcp.json")),
McpConfigScope::Project | McpConfigScope::Local => {
let root = project_hint
.map(PathBuf::from)
.or_else(|| discover_project_root(None))?;
if matches!(scope, McpConfigScope::Project) {
Some(root.join(".mcp.json"))
} else {
Some(root.join(".owlen").join("mcp.local.json"))
}
}
}
}
fn discover_project_root(start: Option<&Path>) -> Option<PathBuf> {
let mut current = start
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())?;
loop {
if current.join(".mcp.json").exists()
|| current.join(".owlen").exists()
|| current.join(".git").exists()
|| current.join("Cargo.toml").exists()
{
return Some(current);
}
if !current.pop() {
break;
}
}
start
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
}
/// General behaviour settings shared across clients
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralSettings {
/// Default provider name for routing
pub default_provider: String,
/// Optional default model id
#[serde(default)]
pub default_model: Option<String>,
/// Whether streaming responses are preferred
#[serde(default = "GeneralSettings::default_streaming")]
pub enable_streaming: bool,
/// Optional path to a project context file automatically injected as system prompt
#[serde(default)]
pub project_context_file: Option<String>,
/// TTL for cached model listings in seconds
#[serde(default = "GeneralSettings::default_model_cache_ttl")]
pub model_cache_ttl_secs: u64,
}
impl GeneralSettings {
fn default_streaming() -> bool {
true
}
fn default_model_cache_ttl() -> u64 {
60
}
/// Duration representation of model cache TTL
pub fn model_cache_ttl(&self) -> Duration {
Duration::from_secs(self.model_cache_ttl_secs.max(5))
}
}
impl Default for GeneralSettings {
fn default() -> Self {
Self {
default_provider: "ollama_local".to_string(),
default_model: Some("llama3.2:latest".to_string()),
enable_streaming: Self::default_streaming(),
project_context_file: Some("OWLEN.md".to_string()),
model_cache_ttl_secs: Self::default_model_cache_ttl(),
}
}
}
/// Operating modes for the MCP subsystem.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum McpMode {
/// Prefer remote MCP servers when configured, but allow local fallback.
#[serde(alias = "enabled", alias = "auto")]
RemotePreferred,
/// Require a configured remote MCP server; fail if none are available.
RemoteOnly,
/// Always use the in-process MCP server for tooling.
#[serde(alias = "local")]
LocalOnly,
/// Compatibility shim for pre-v1.0 behaviour; treated as `local_only`.
Legacy,
/// Disable MCP entirely (not recommended).
Disabled,
}
impl Default for McpMode {
fn default() -> Self {
Self::RemotePreferred
}
}
impl McpMode {
/// Whether this mode requires a remote MCP server.
pub const fn requires_remote(self) -> bool {
matches!(self, Self::RemoteOnly)
}
/// Whether this mode prefers to use a remote MCP server when available.
pub const fn prefers_remote(self) -> bool {
matches!(self, Self::RemotePreferred | Self::RemoteOnly)
}
/// Whether this mode should operate purely locally.
pub const fn is_local(self) -> bool {
matches!(self, Self::LocalOnly | Self::Legacy)
}
}
/// MCP (Multi-Client-Provider) settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpSettings {
/// Operating mode for MCP integration.
#[serde(default)]
pub mode: McpMode,
/// Allow falling back to the local MCP client when remote startup fails.
#[serde(default = "McpSettings::default_allow_fallback")]
pub allow_fallback: bool,
/// Emit a warning when the deprecated `legacy` mode is used.
#[serde(default = "McpSettings::default_warn_on_legacy")]
pub warn_on_legacy: bool,
}
impl McpSettings {
const fn default_allow_fallback() -> bool {
true
}
const fn default_warn_on_legacy() -> bool {
true
}
fn apply_backward_compat(&mut self) {
if self.mode == McpMode::Legacy && self.warn_on_legacy {
log::warn!(
"MCP legacy mode detected. This mode will be removed in a future release; \
switch to 'local_only' or 'remote_preferred' after verifying your setup."
);
}
}
}
impl Default for McpSettings {
fn default() -> Self {
let mut settings = Self {
mode: McpMode::default(),
allow_fallback: Self::default_allow_fallback(),
warn_on_legacy: Self::default_warn_on_legacy(),
};
settings.apply_backward_compat();
settings
}
}
/// Privacy controls governing network access and storage
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrivacySettings {
#[serde(default = "PrivacySettings::default_remote_search")]
pub enable_remote_search: bool,
#[serde(default)]
pub cache_web_results: bool,
#[serde(default)]
pub retain_history_days: u32,
#[serde(default = "PrivacySettings::default_require_consent")]
pub require_consent_per_session: bool,
#[serde(default = "PrivacySettings::default_encrypt_local_data")]
pub encrypt_local_data: bool,
}
impl PrivacySettings {
const fn default_remote_search() -> bool {
false
}
const fn default_require_consent() -> bool {
true
}
const fn default_encrypt_local_data() -> bool {
true
}
}
impl Default for PrivacySettings {
fn default() -> Self {
Self {
enable_remote_search: Self::default_remote_search(),
cache_web_results: false,
retain_history_days: 0,
require_consent_per_session: Self::default_require_consent(),
encrypt_local_data: Self::default_encrypt_local_data(),
}
}
}
/// Security settings that constrain tool execution
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecuritySettings {
#[serde(default = "SecuritySettings::default_enable_sandboxing")]
pub enable_sandboxing: bool,
#[serde(default = "SecuritySettings::default_timeout")]
pub sandbox_timeout_seconds: u64,
#[serde(default = "SecuritySettings::default_max_memory")]
pub max_memory_mb: u64,
#[serde(default = "SecuritySettings::default_allowed_tools")]
pub allowed_tools: Vec<String>,
}
impl SecuritySettings {
const fn default_enable_sandboxing() -> bool {
true
}
const fn default_timeout() -> u64 {
30
}
const fn default_max_memory() -> u64 {
512
}
fn default_allowed_tools() -> Vec<String> {
vec![
"web_search".to_string(),
"web_scrape".to_string(),
"code_exec".to_string(),
"file_write".to_string(),
"file_delete".to_string(),
]
}
}
impl Default for SecuritySettings {
fn default() -> Self {
Self {
enable_sandboxing: Self::default_enable_sandboxing(),
sandbox_timeout_seconds: Self::default_timeout(),
max_memory_mb: Self::default_max_memory(),
allowed_tools: Self::default_allowed_tools(),
}
}
}
/// Per-tool configuration toggles
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ToolSettings {
#[serde(default)]
pub web_search: WebSearchToolConfig,
#[serde(default)]
pub code_exec: CodeExecToolConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebSearchToolConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub api_key: String,
#[serde(default = "WebSearchToolConfig::default_max_results")]
pub max_results: u32,
}
impl WebSearchToolConfig {
const fn default_max_results() -> u32 {
5
}
}
impl Default for WebSearchToolConfig {
fn default() -> Self {
Self {
enabled: false,
api_key: String::new(),
max_results: Self::default_max_results(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CodeExecToolConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "CodeExecToolConfig::default_allowed_languages")]
pub allowed_languages: Vec<String>,
#[serde(default = "CodeExecToolConfig::default_timeout")]
pub timeout_seconds: u64,
}
impl CodeExecToolConfig {
fn default_allowed_languages() -> Vec<String> {
vec!["python".to_string(), "javascript".to_string()]
}
const fn default_timeout() -> u64 {
30
}
}
impl Default for CodeExecToolConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_languages: Self::default_allowed_languages(),
timeout_seconds: Self::default_timeout(),
}
}
}
/// UI preferences that consumers can respect as needed
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiSettings {
#[serde(default = "UiSettings::default_theme")]
pub theme: String,
#[serde(default = "UiSettings::default_word_wrap")]
pub word_wrap: bool,
#[serde(default = "UiSettings::default_max_history_lines")]
pub max_history_lines: usize,
#[serde(
rename = "role_label",
alias = "show_role_labels",
default = "UiSettings::default_role_label_mode",
deserialize_with = "UiSettings::deserialize_role_label_mode"
)]
pub role_label_mode: RoleLabelDisplay,
#[serde(default = "UiSettings::default_wrap_column")]
pub wrap_column: u16,
#[serde(default = "UiSettings::default_show_onboarding")]
pub show_onboarding: bool,
#[serde(default = "UiSettings::default_input_max_rows")]
pub input_max_rows: u16,
#[serde(default = "UiSettings::default_scrollback_lines")]
pub scrollback_lines: usize,
#[serde(default = "UiSettings::default_show_cursor_outside_insert")]
pub show_cursor_outside_insert: bool,
#[serde(default = "UiSettings::default_syntax_highlighting")]
pub syntax_highlighting: bool,
#[serde(default = "UiSettings::default_render_markdown")]
pub render_markdown: bool,
#[serde(default = "UiSettings::default_show_timestamps")]
pub show_timestamps: bool,
#[serde(default = "UiSettings::default_icon_mode")]
pub icon_mode: IconMode,
#[serde(default)]
pub keymap_path: Option<String>,
}
/// Preference for which symbol set to render in the terminal UI.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum IconMode {
/// Automatically detect support for Nerd Font glyphs.
#[default]
Auto,
/// Use only ASCII-safe symbols.
Ascii,
/// Force Nerd Font glyphs regardless of detection heuristics.
Nerd,
}
impl UiSettings {
fn default_theme() -> String {
"default_dark".to_string()
}
fn default_word_wrap() -> bool {
true
}
fn default_max_history_lines() -> usize {
2000
}
const fn default_role_label_mode() -> RoleLabelDisplay {
RoleLabelDisplay::Above
}
fn default_wrap_column() -> u16 {
100
}
const fn default_show_onboarding() -> bool {
true
}
const fn default_input_max_rows() -> u16 {
5
}
const fn default_scrollback_lines() -> usize {
2000
}
const fn default_show_cursor_outside_insert() -> bool {
false
}
const fn default_syntax_highlighting() -> bool {
true
}
const fn default_render_markdown() -> bool {
true
}
const fn default_show_timestamps() -> bool {
true
}
const fn default_icon_mode() -> IconMode {
IconMode::Auto
}
fn deserialize_role_label_mode<'de, D>(
deserializer: D,
) -> std::result::Result<RoleLabelDisplay, D::Error>
where
D: Deserializer<'de>,
{
struct RoleLabelModeVisitor;
impl<'de> Visitor<'de> for RoleLabelModeVisitor {
type Value = RoleLabelDisplay;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("`inline`, `above`, `none`, or a legacy boolean")
}
fn visit_str<E>(self, v: &str) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
match v.trim().to_ascii_lowercase().as_str() {
"inline" => Ok(RoleLabelDisplay::Inline),
"above" => Ok(RoleLabelDisplay::Above),
"none" => Ok(RoleLabelDisplay::None),
other => Err(de::Error::unknown_variant(
other,
&["inline", "above", "none"],
)),
}
}
fn visit_string<E>(self, v: String) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
self.visit_str(&v)
}
fn visit_bool<E>(self, v: bool) -> std::result::Result<Self::Value, E>
where
E: de::Error,
{
if v {
Ok(RoleLabelDisplay::Above)
} else {
Ok(RoleLabelDisplay::None)
}
}
}
deserializer.deserialize_any(RoleLabelModeVisitor)
}
}
impl Default for UiSettings {
fn default() -> Self {
Self {
theme: Self::default_theme(),
word_wrap: Self::default_word_wrap(),
max_history_lines: Self::default_max_history_lines(),
role_label_mode: Self::default_role_label_mode(),
wrap_column: Self::default_wrap_column(),
show_onboarding: Self::default_show_onboarding(),
input_max_rows: Self::default_input_max_rows(),
scrollback_lines: Self::default_scrollback_lines(),
show_cursor_outside_insert: Self::default_show_cursor_outside_insert(),
syntax_highlighting: Self::default_syntax_highlighting(),
render_markdown: Self::default_render_markdown(),
show_timestamps: Self::default_show_timestamps(),
icon_mode: Self::default_icon_mode(),
keymap_path: None,
}
}
}
/// Storage related preferences
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StorageSettings {
#[serde(default = "StorageSettings::default_conversation_dir")]
pub conversation_dir: Option<String>,
#[serde(default = "StorageSettings::default_auto_save")]
pub auto_save_sessions: bool,
#[serde(default = "StorageSettings::default_max_sessions")]
pub max_saved_sessions: usize,
#[serde(default = "StorageSettings::default_session_timeout")]
pub session_timeout_minutes: u64,
#[serde(default = "StorageSettings::default_generate_descriptions")]
pub generate_descriptions: bool,
}
impl StorageSettings {
fn default_conversation_dir() -> Option<String> {
None
}
fn default_auto_save() -> bool {
true
}
fn default_max_sessions() -> usize {
25
}
fn default_session_timeout() -> u64 {
120
}
fn default_generate_descriptions() -> bool {
true
}
/// Resolve storage directory path
/// Uses platform-specific data directory if not explicitly configured:
/// - Linux: ~/.local/share/owlen/sessions
/// - Windows: %APPDATA%\owlen\sessions
/// - macOS: ~/Library/Application Support/owlen/sessions
pub fn conversation_path(&self) -> PathBuf {
if let Some(ref dir) = self.conversation_dir {
PathBuf::from(shellexpand::tilde(dir).as_ref())
} else {
// Use platform-specific data directory
dirs::data_local_dir()
.map(|d| d.join("owlen").join("sessions"))
.unwrap_or_else(|| PathBuf::from("./owlen_sessions"))
}
}
}
impl Default for StorageSettings {
fn default() -> Self {
Self {
conversation_dir: None, // Use platform-specific defaults
auto_save_sessions: Self::default_auto_save(),
max_saved_sessions: Self::default_max_sessions(),
session_timeout_minutes: Self::default_session_timeout(),
generate_descriptions: Self::default_generate_descriptions(),
}
}
}
/// Input handling preferences shared across clients
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputSettings {
#[serde(default = "InputSettings::default_multiline")]
pub multiline: bool,
#[serde(default = "InputSettings::default_history_size")]
pub history_size: usize,
#[serde(default = "InputSettings::default_tab_width")]
pub tab_width: u8,
#[serde(default = "InputSettings::default_confirm_send")]
pub confirm_send: bool,
}
impl InputSettings {
fn default_multiline() -> bool {
true
}
fn default_history_size() -> usize {
100
}
fn default_tab_width() -> u8 {
4
}
fn default_confirm_send() -> bool {
false
}
}
impl Default for InputSettings {
fn default() -> Self {
Self {
multiline: Self::default_multiline(),
history_size: Self::default_history_size(),
tab_width: Self::default_tab_width(),
confirm_send: Self::default_confirm_send(),
}
}
}
/// Convenience accessor for an Ollama provider entry, creating a default if missing
pub fn ensure_ollama_config(config: &mut Config) -> &ProviderConfig {
ensure_provider_config(config, "ollama_local")
}
/// Ensure a provider configuration exists for the requested provider name and return a mutable reference.
pub fn ensure_provider_config_mut<'a>(
config: &'a mut Config,
provider_name: &str,
) -> &'a mut ProviderConfig {
let key = normalize_provider_key(provider_name);
let entry = config.providers.entry(key.clone()).or_insert_with(|| {
let mut default = default_provider_config_for(&key).unwrap_or_else(|| ProviderConfig {
enabled: true,
provider_type: canonical_provider_type(&key),
base_url: None,
api_key: None,
api_key_env: None,
extra: HashMap::new(),
});
if default.provider_type.is_empty() {
default.provider_type = canonical_provider_type(&key);
}
default
});
if entry.provider_type.is_empty() {
entry.provider_type = canonical_provider_type(&key);
}
entry
}
/// Ensure a provider configuration exists for the requested provider name
pub fn ensure_provider_config<'a>(
config: &'a mut Config,
provider_name: &str,
) -> &'a ProviderConfig {
let entry = ensure_provider_config_mut(config, provider_name);
&*entry
}
/// Calculate absolute timeout for session data based on configuration
pub fn session_timeout(config: &Config) -> Duration {
Duration::from_secs(config.storage.session_timeout_minutes.max(1) * 60)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expand_provider_env_vars_resolves_api_key() {
unsafe {
std::env::set_var("OWLEN_TEST_API_KEY", "super-secret");
}
let mut config = Config::default();
if let Some(ollama_local) = config.providers.get_mut("ollama_local") {
ollama_local.api_key = Some("${OWLEN_TEST_API_KEY}".to_string());
}
config
.expand_provider_env_vars()
.expect("environment expansion succeeded");
assert_eq!(
config.providers["ollama_local"].api_key.as_deref(),
Some("super-secret")
);
unsafe {
std::env::remove_var("OWLEN_TEST_API_KEY");
}
}
#[test]
fn expand_provider_env_vars_errors_for_missing_variable() {
unsafe {
std::env::remove_var("OWLEN_TEST_MISSING");
}
let mut config = Config::default();
if let Some(ollama_local) = config.providers.get_mut("ollama_local") {
ollama_local.api_key = Some("${OWLEN_TEST_MISSING}".to_string());
}
let error = config
.expand_provider_env_vars()
.expect_err("missing variables should error");
match error {
crate::Error::Config(message) => {
assert!(message.contains("OWLEN_TEST_MISSING"));
}
other => panic!("expected config error, got {other:?}"),
}
}
#[test]
fn test_storage_platform_specific_paths() {
let config = Config::default();
let path = config.storage.conversation_path();
// Verify it contains owlen/sessions
assert!(path.to_string_lossy().contains("owlen"));
assert!(path.to_string_lossy().contains("sessions"));
// Platform-specific checks
#[cfg(target_os = "linux")]
{
// Linux should use ~/.local/share/owlen/sessions
assert!(path.to_string_lossy().contains(".local/share"));
}
#[cfg(target_os = "windows")]
{
// Windows should use AppData
assert!(path.to_string_lossy().contains("AppData"));
}
#[cfg(target_os = "macos")]
{
// macOS should use ~/Library/Application Support
assert!(
path.to_string_lossy()
.contains("Library/Application Support")
);
}
println!("Config conversation path: {}", path.display());
}
#[test]
fn test_storage_custom_path() {
let mut config = Config::default();
config.storage.conversation_dir = Some("~/custom/path".to_string());
let path = config.storage.conversation_path();
assert!(path.to_string_lossy().contains("custom/path"));
}
#[test]
fn default_config_contains_local_provider() {
let config = Config::default();
let local = config
.providers
.get("ollama_local")
.expect("default local provider");
assert!(local.enabled);
assert_eq!(local.base_url.as_deref(), Some(OLLAMA_LOCAL_BASE_URL));
let cloud = config
.providers
.get("ollama_cloud")
.expect("default cloud provider");
assert!(!cloud.enabled);
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_CLOUD_API_KEY_ENV));
}
#[test]
fn ensure_provider_config_aliases_cloud_defaults() {
let mut config = Config::default();
config.providers.clear();
let cloud = ensure_provider_config(&mut config, "ollama-cloud");
assert_eq!(cloud.provider_type, "ollama_cloud");
assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL));
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_CLOUD_API_KEY_ENV));
assert!(config.providers.contains_key("ollama_cloud"));
assert!(!config.providers.contains_key("ollama-cloud"));
}
#[test]
fn migrate_ollama_cloud_underscore_key() {
let mut config = Config::default();
config.providers.clear();
config.providers.insert(
"ollama_cloud".to_string(),
ProviderConfig {
enabled: true,
provider_type: "ollama_cloud".to_string(),
base_url: Some("https://api.ollama.com".to_string()),
api_key: Some("secret".to_string()),
api_key_env: None,
extra: HashMap::new(),
},
);
config.apply_schema_migrations("1.0.0");
assert!(config.providers.get("ollama_cloud").is_some());
let cloud = config
.providers
.get("ollama_cloud")
.expect("migrated config");
assert!(cloud.enabled);
assert_eq!(cloud.provider_type, "ollama_cloud");
assert_eq!(cloud.base_url.as_deref(), Some("https://api.ollama.com"));
assert_eq!(cloud.api_key.as_deref(), Some("secret"));
}
#[test]
fn migration_sets_cloud_mode_for_cloud_base() {
let mut config = Config::default();
if let Some(ollama) = config.providers.get_mut("ollama_local") {
ollama.base_url = Some(OLLAMA_CLOUD_BASE_URL.to_string());
}
config.apply_schema_migrations("1.4.0");
let cloud = config
.providers
.get("ollama_cloud")
.expect("cloud provider created");
assert!(cloud.enabled);
assert_eq!(cloud.base_url.as_deref(), Some(OLLAMA_CLOUD_BASE_URL));
assert_eq!(cloud.api_key_env.as_deref(), Some(OLLAMA_CLOUD_API_KEY_ENV));
}
#[test]
fn migrate_legacy_monolithic_ollama_entry() {
let mut config = Config::default();
config.providers.clear();
config.providers.insert(
"ollama".to_string(),
ProviderConfig {
enabled: true,
provider_type: "ollama".to_string(),
base_url: Some(OLLAMA_LOCAL_BASE_URL.to_string()),
api_key: None,
api_key_env: None,
extra: HashMap::new(),
},
);
config.apply_schema_migrations("1.2.0");
let local = config
.providers
.get("ollama_local")
.expect("local provider migrated");
assert!(local.enabled);
assert_eq!(local.base_url.as_deref(), Some(OLLAMA_LOCAL_BASE_URL));
let cloud = config
.providers
.get("ollama_cloud")
.expect("cloud provider placeholder");
assert!(!cloud.enabled);
}
#[test]
fn migrate_legacy_provider_tables_moves_top_level_entries() {
let mut document: toml::Value = toml::from_str(
r#"
[ollama]
base_url = "http://localhost:11434"
[general]
default_provider = "ollama"
"#,
)
.expect("valid inline config");
migrate_legacy_provider_tables(&mut document);
let providers = document
.get("providers")
.and_then(|value| value.as_table())
.expect("providers table present");
assert!(providers.contains_key("ollama"));
assert!(providers["ollama"].get("base_url").is_some());
assert!(document.get("ollama").is_none());
}
#[test]
fn validate_rejects_missing_default_provider() {
let mut config = Config::default();
config.general.default_provider = "does-not-exist".to_string();
let result = config.validate();
assert!(
matches!(result, Err(crate::Error::Config(message)) if message.contains("Default provider"))
);
}
#[test]
fn validate_rejects_remote_only_without_servers() {
let mut config = Config::default();
config.mcp.mode = McpMode::RemoteOnly;
config.mcp_servers.clear();
let result = config.validate();
assert!(
matches!(result, Err(crate::Error::Config(message)) if message.contains("remote_only"))
);
}
#[test]
fn validate_rejects_unknown_transport() {
let mut config = Config::default();
config.mcp_servers = vec![McpServerConfig {
name: "bad".into(),
command: "binary".into(),
transport: "udp".into(),
args: Vec::new(),
env: std::collections::HashMap::new(),
oauth: None,
}];
let result = config.validate();
assert!(
matches!(result, Err(crate::Error::Config(message)) if message.contains("transport"))
);
}
#[test]
fn validate_accepts_local_only_configuration() {
let mut config = Config::default();
config.mcp.mode = McpMode::LocalOnly;
assert!(config.validate().is_ok());
}
#[test]
fn refresh_mcp_servers_merges_scopes_with_precedence() {
let temp = tempfile::tempdir().expect("tempdir");
let project_root = temp.path();
std::fs::write(
project_root.join(".mcp.json"),
r#"{
"servers": [
{ "name": "shared", "command": "project-cmd", "transport": "stdio" },
{ "name": "project-only", "command": "proj", "transport": "stdio" }
],
"resources": [
{ "server": "github", "uri": "issue://123", "title": "Project Issue" },
{ "server": "docs", "uri": "page://start", "title": "Project Doc" }
]
}"#,
)
.expect("write project scope");
let local_dir = project_root.join(".owlen");
std::fs::create_dir_all(&local_dir).expect("local dir");
std::fs::write(
local_dir.join("mcp.local.json"),
r#"{
"servers": [
{ "name": "shared", "command": "local-cmd", "transport": "stdio" }
],
"resources": [
{ "server": "github", "uri": "issue://123", "title": "Local Override" }
]
}"#,
)
.expect("write local scope");
let mut config = Config::default();
config.mcp_servers.push(McpServerConfig {
name: "shared".into(),
command: "user-cmd".into(),
args: Vec::new(),
transport: "stdio".into(),
env: std::collections::HashMap::new(),
oauth: None,
});
config.mcp_resources.push(McpResourceConfig {
server: "github".into(),
uri: "issue://123".into(),
title: Some("User Issue".into()),
description: None,
});
config
.refresh_mcp_servers(Some(project_root))
.expect("refresh scopes");
// We should have four scoped entries (user + two project + local) and precedence should select local
assert_eq!(config.scoped_mcp_servers().len(), 4);
let effective = config.effective_mcp_servers();
assert_eq!(effective.len(), 2); // shared + project-only
assert_eq!(effective[0].command, "local-cmd");
assert_eq!(effective[0].name, "shared");
assert_eq!(config.scoped_mcp_resources().len(), 4);
let effective_resources = config.effective_mcp_resources();
assert_eq!(effective_resources.len(), 2);
assert_eq!(
effective_resources
.iter()
.find(|res| res.server == "github")
.and_then(|res| res.title.as_deref()),
Some("Local Override")
);
}
#[test]
fn remove_mcp_server_reports_scope() {
let temp = tempfile::tempdir().expect("tempdir");
let project_root = temp.path();
std::fs::write(
project_root.join(".mcp.json"),
r#"{ "servers": [{ "name": "project", "command": "proj", "transport": "stdio" }] }"#,
)
.expect("write project scope");
let mut config = Config::default();
config.mcp_servers.push(McpServerConfig {
name: "user".into(),
command: "user".into(),
args: Vec::new(),
transport: "stdio".into(),
env: std::collections::HashMap::new(),
oauth: None,
});
config
.refresh_mcp_servers(Some(project_root))
.expect("refresh scopes");
// Remove without specifying scope should pick highest precedence (project)
let removed_scope = config
.remove_mcp_server(None, "project", Some(project_root))
.expect("remove call");
assert_eq!(removed_scope, Some(McpConfigScope::Project));
// Remove the remaining user scope explicitly
let removed_scope = config
.remove_mcp_server(Some(McpConfigScope::User), "user", Some(project_root))
.expect("remove user");
assert_eq!(removed_scope, Some(McpConfigScope::User));
}
}