- Switch to `ollama-rs` crate for chat, model listing, and streaming. - Remove custom request building, authentication handling, and debug logging. - Drop unsupported tool conversion; now ignore tool descriptors with a warning. - Refactor model fetching to use local model info and optional cloud details. - Consolidate error mapping via `map_ollama_error`. - Update health check to use the new HTTP client. - Delete obsolete `provider_interface.rs` test as the provider interface has changed.
1068 lines
33 KiB
Rust
1068 lines
33 KiB
Rust
use crate::mode::ModeConfig;
|
|
use crate::provider::ProviderConfig;
|
|
use crate::Result;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
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.1.0";
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
let mut providers = HashMap::new();
|
|
providers.insert("ollama".to_string(), default_ollama_provider_config());
|
|
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
impl McpServerConfig {
|
|
fn default_transport() -> String {
|
|
"stdio".to_string()
|
|
}
|
|
}
|
|
|
|
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 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()) {
|
|
if 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.validate()?;
|
|
Ok(config)
|
|
} else {
|
|
let mut config = Config::default();
|
|
config.expand_provider_env_vars()?;
|
|
Ok(config)
|
|
}
|
|
}
|
|
|
|
/// Persist configuration to disk
|
|
pub fn save(&self, path: Option<&Path>) -> Result<()> {
|
|
self.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> {
|
|
self.providers.get(name)
|
|
}
|
|
|
|
/// Update or insert a provider configuration
|
|
pub fn upsert_provider(&mut self, name: impl Into<String>, config: ProviderConfig) {
|
|
self.providers.insert(name.into(), 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() {
|
|
if 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".to_string();
|
|
}
|
|
|
|
ensure_provider_config(self, "ollama");
|
|
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(())
|
|
}
|
|
|
|
/// 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()?;
|
|
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
|
|
);
|
|
}
|
|
|
|
if let Some(legacy_cloud) = self.providers.remove("ollama_cloud") {
|
|
self.merge_legacy_ollama_provider(legacy_cloud);
|
|
}
|
|
|
|
if let Some(legacy_cloud) = self.providers.remove("ollama-cloud") {
|
|
self.merge_legacy_ollama_provider(legacy_cloud);
|
|
}
|
|
|
|
self.schema_version = CONFIG_SCHEMA_VERSION.to_string();
|
|
}
|
|
|
|
fn merge_legacy_ollama_provider(&mut self, mut legacy_cloud: ProviderConfig) {
|
|
use std::collections::hash_map::Entry;
|
|
|
|
legacy_cloud.provider_type = "ollama".to_string();
|
|
|
|
match self.providers.entry("ollama".to_string()) {
|
|
Entry::Occupied(mut entry) => {
|
|
let target = entry.get_mut();
|
|
if target.base_url.is_none() {
|
|
target.base_url = legacy_cloud.base_url.take();
|
|
}
|
|
if target.api_key.is_none() {
|
|
target.api_key = legacy_cloud.api_key.take();
|
|
}
|
|
if target.extra.is_empty() && !legacy_cloud.extra.is_empty() {
|
|
target.extra = legacy_cloud.extra;
|
|
}
|
|
}
|
|
Entry::Vacant(entry) => {
|
|
entry.insert(legacy_cloud);
|
|
}
|
|
}
|
|
}
|
|
|
|
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<()> {
|
|
match self.mcp.mode {
|
|
McpMode::RemoteOnly => {
|
|
if self.mcp_servers.is_empty() {
|
|
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 && self.mcp_servers.is_empty() {
|
|
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<()> {
|
|
for server in &self.mcp_servers {
|
|
if server.name.trim().is_empty() {
|
|
return Err(crate::Error::Config(
|
|
"Each [[mcp_servers]] entry must include a non-empty name".to_string(),
|
|
));
|
|
}
|
|
|
|
if server.command.trim().is_empty() {
|
|
return Err(crate::Error::Config(format!(
|
|
"MCP server '{}' must define a command or endpoint",
|
|
server.name
|
|
)));
|
|
}
|
|
|
|
let transport = server.transport.to_lowercase();
|
|
if !matches!(transport.as_str(), "stdio" | "http" | "websocket") {
|
|
return Err(crate::Error::Config(format!(
|
|
"Unknown MCP transport '{}' for server '{}'",
|
|
server.transport, server.name
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn default_ollama_provider_config() -> ProviderConfig {
|
|
ProviderConfig {
|
|
provider_type: "ollama".to_string(),
|
|
base_url: Some("http://localhost:11434".to_string()),
|
|
api_key: None,
|
|
extra: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
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_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())
|
|
}
|
|
|
|
/// 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".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(default = "UiSettings::default_show_role_labels")]
|
|
pub show_role_labels: bool,
|
|
#[serde(default = "UiSettings::default_wrap_column")]
|
|
pub wrap_column: u16,
|
|
#[serde(default = "UiSettings::default_show_onboarding")]
|
|
pub show_onboarding: bool,
|
|
}
|
|
|
|
impl UiSettings {
|
|
fn default_theme() -> String {
|
|
"default_dark".to_string()
|
|
}
|
|
|
|
fn default_word_wrap() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_max_history_lines() -> usize {
|
|
2000
|
|
}
|
|
|
|
fn default_show_role_labels() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_wrap_column() -> u16 {
|
|
100
|
|
}
|
|
|
|
const fn default_show_onboarding() -> bool {
|
|
true
|
|
}
|
|
}
|
|
|
|
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(),
|
|
show_role_labels: Self::default_show_role_labels(),
|
|
wrap_column: Self::default_wrap_column(),
|
|
show_onboarding: Self::default_show_onboarding(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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")
|
|
}
|
|
|
|
/// 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 {
|
|
use std::collections::hash_map::Entry;
|
|
|
|
if matches!(provider_name, "ollama_cloud" | "ollama-cloud") {
|
|
return ensure_provider_config(config, "ollama");
|
|
}
|
|
|
|
match config.providers.entry(provider_name.to_string()) {
|
|
Entry::Occupied(entry) => entry.into_mut(),
|
|
Entry::Vacant(entry) => {
|
|
let default = match provider_name {
|
|
"ollama" => default_ollama_provider_config(),
|
|
other => ProviderConfig {
|
|
provider_type: other.to_string(),
|
|
base_url: None,
|
|
api_key: None,
|
|
extra: HashMap::new(),
|
|
},
|
|
};
|
|
entry.insert(default)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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() {
|
|
std::env::set_var("OWLEN_TEST_API_KEY", "super-secret");
|
|
|
|
let mut config = Config::default();
|
|
if let Some(ollama) = config.providers.get_mut("ollama") {
|
|
ollama.api_key = Some("${OWLEN_TEST_API_KEY}".to_string());
|
|
}
|
|
|
|
config
|
|
.expand_provider_env_vars()
|
|
.expect("environment expansion succeeded");
|
|
|
|
assert_eq!(
|
|
config.providers["ollama"].api_key.as_deref(),
|
|
Some("super-secret")
|
|
);
|
|
|
|
std::env::remove_var("OWLEN_TEST_API_KEY");
|
|
}
|
|
|
|
#[test]
|
|
fn expand_provider_env_vars_errors_for_missing_variable() {
|
|
std::env::remove_var("OWLEN_TEST_MISSING");
|
|
|
|
let mut config = Config::default();
|
|
if let Some(ollama) = config.providers.get_mut("ollama") {
|
|
ollama.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();
|
|
assert!(config.providers.contains_key("ollama"));
|
|
}
|
|
|
|
#[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");
|
|
assert_eq!(cloud.base_url.as_deref(), Some("http://localhost:11434"));
|
|
assert!(config.providers.contains_key("ollama"));
|
|
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 {
|
|
provider_type: "ollama_cloud".to_string(),
|
|
base_url: Some("https://api.ollama.com".to_string()),
|
|
api_key: Some("secret".to_string()),
|
|
extra: HashMap::new(),
|
|
},
|
|
);
|
|
|
|
config.apply_schema_migrations("1.0.0");
|
|
|
|
assert!(config.providers.get("ollama_cloud").is_none());
|
|
assert!(config.providers.get("ollama-cloud").is_none());
|
|
let cloud = config.providers.get("ollama").expect("migrated config");
|
|
assert_eq!(cloud.provider_type, "ollama");
|
|
assert_eq!(cloud.base_url.as_deref(), Some("https://api.ollama.com"));
|
|
assert_eq!(cloud.api_key.as_deref(), Some("secret"));
|
|
}
|
|
|
|
#[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(),
|
|
}];
|
|
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());
|
|
}
|
|
}
|