Introduce `ui.scrollback_lines` (default 2000) to cap the number of chat lines kept in memory, with `0` disabling trimming. Implement automatic trimming of older lines, maintain a scroll offset, and show a “↓ New messages (press G)” badge when new messages arrive off‑screen. Update core UI settings, TUI rendering, chat app state, migrations, documentation, and changelog to reflect the new feature.
1083 lines
33 KiB
Rust
1083 lines
33 KiB
Rust
use crate::ProviderConfig;
|
|
use crate::Result;
|
|
use crate::mode::ModeConfig;
|
|
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.2.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())
|
|
&& 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()
|
|
&& 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,
|
|
#[serde(default = "UiSettings::default_input_max_rows")]
|
|
pub input_max_rows: u16,
|
|
#[serde(default = "UiSettings::default_scrollback_lines")]
|
|
pub scrollback_lines: usize,
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
const fn default_input_max_rows() -> u16 {
|
|
5
|
|
}
|
|
|
|
const fn default_scrollback_lines() -> usize {
|
|
2000
|
|
}
|
|
}
|
|
|
|
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(),
|
|
input_max_rows: Self::default_input_max_rows(),
|
|
scrollback_lines: Self::default_scrollback_lines(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
}
|
|
}
|