use super::{Options, RenderedOutput, SyncTarget};
#[cfg(not(target_os = "windows"))]
use crate::io::home_directory;
use crate::io::{read_file, ApiResult};
use crate::prelude::PathBuf;
use crate::schema::agent::ModelDetails;
#[cfg(not(target_os = "windows"))]
use crate::util::constants::app::DEFAULT_GOOSE_CONFIG_PATH;
#[cfg(target_os = "windows")]
use crate::util::constants::app::DEFAULT_GOOSE_WINDOWS_CONFIG_PATH;
use alloc::string::{String, ToString};
use color_eyre::eyre::eyre;
#[cfg(target_os = "windows")]
use directories::BaseDirs;
use serde::{Deserialize, Serialize};
use serde_norway::Value;
use serde_with::skip_serializing_none;
use validator::Validate;
#[skip_serializing_none]
#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
#[serde(rename_all = "camelCase")]
pub struct Config {
#[serde(skip_serializing)]
#[validate(length(min = 1))]
pub path: Option<String>,
#[serde(default = "default_host")]
#[validate(url)]
pub host: String,
#[serde(default = "default_base_path")]
#[validate(length(min = 1))]
pub base_path: String,
#[validate(length(min = 1))]
pub default_model: Option<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
path: None,
host: default_host(),
base_path: default_base_path(),
default_model: None,
}
}
}
impl SyncTarget for Config {
const COMMAND: &'static str = "goose";
fn merge(self, overrides: Self) -> Self {
Self {
path: overrides.path.or(self.path),
host: overrides.host,
base_path: overrides.base_path,
default_model: overrides.default_model.or(self.default_model),
}
}
fn merge_cli_overrides(self, overrides: Self) -> Self {
Self {
path: overrides.path.or(self.path),
..self
}
}
fn resolve_path(explicit: Option<&str>) -> ApiResult<PathBuf> {
explicit.map(PathBuf::from).map_or_else(
|| {
#[cfg(target_os = "windows")]
let path = BaseDirs::new()
.map(|directories| directories.config_dir().join(DEFAULT_GOOSE_WINDOWS_CONFIG_PATH))
.ok_or_else(|| eyre!("Failed to resolve platform configuration directory"));
#[cfg(not(target_os = "windows"))]
let path = home_directory(DEFAULT_GOOSE_CONFIG_PATH);
path
},
Ok,
)
}
fn render(&self, options: Options<'_>) -> ApiResult<RenderedOutput> {
Self::resolve_path(self.path.as_deref()).and_then(|path| {
path.is_file()
.then(|| read_file(path.clone()))
.transpose()
.map(|content| content.unwrap_or_default())
.and_then(|before| {
match before.is_empty() {
| true => Ok(Value::Mapping(Default::default())),
| false => serde_norway::from_str(&before).map_err(|why| eyre!("Failed to parse existing Goose config: {why}")),
}
.and_then(|existing| self.upsert(existing, options.models))
.and_then(|updated| serde_norway::to_string(&updated).map_err(|why| eyre!("Failed to serialize Goose config: {why}")))
.map(|content| RenderedOutput {
target: "Goose",
path,
before,
content,
})
})
})
}
}
impl Config {
pub fn upsert(&self, existing: Value, models: &[ModelDetails]) -> ApiResult<Value> {
self.default_model
.as_ref()
.or_else(|| models.iter().find_map(|model| model.id.as_ref()))
.ok_or_else(|| eyre!("Goose synchronization requires at least one model"))
.and_then(|model| {
match existing {
| Value::Mapping(root) => Ok(root),
| _ => Err(eyre!("Goose configuration must be a YAML mapping")),
}
.map(|mut root| {
let providers_key = Value::String("providers".to_string());
let mut providers = root
.remove(&providers_key)
.and_then(|value| value.as_mapping().cloned())
.unwrap_or_default();
let openai_key = Value::String("openai".to_string());
let mut openai = providers
.remove(&openai_key)
.and_then(|value| value.as_mapping().cloned())
.unwrap_or_default();
openai.insert(Value::String("enabled".to_string()), Value::Bool(true));
openai.insert(Value::String("model".to_string()), Value::String(model.clone()));
openai.insert(Value::String("configured".to_string()), Value::Bool(true));
providers.insert(openai_key, Value::Mapping(openai));
root.insert(Value::String("active_provider".to_string()), Value::String("openai".to_string()));
root.insert(providers_key, Value::Mapping(providers));
root.insert(Value::String("OPENAI_HOST".to_string()), Value::String(self.host.clone()));
root.insert(Value::String("OPENAI_BASE_PATH".to_string()), Value::String(self.base_path.clone()));
Value::Mapping(root)
})
})
}
}
fn default_host() -> String {
"http://localhost:8080".to_string()
}
fn default_base_path() -> String {
"v1/chat/completions".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_upsert_preserves_unrelated_settings_and_provider_fields() {
let existing: Value =
serde_norway::from_str("GOOSE_MODE: approve\nproviders:\n anthropic:\n enabled: true\n openai:\n custom: retained\n").unwrap();
let models = [ModelDetails::init().id("qwen").build()];
let updated = Config::default().upsert(existing, &models).unwrap();
let rendered = serde_norway::to_string(&updated).unwrap();
assert!(rendered.contains("GOOSE_MODE: approve"));
assert!(rendered.contains("anthropic:"));
assert!(rendered.contains("custom: retained"));
assert!(rendered.contains("model: qwen"));
assert!(rendered.contains("OPENAI_BASE_PATH: v1/chat/completions"));
}
}