1use super::{Options, RenderedOutput, SyncTarget};
3#[cfg(not(target_os = "windows"))]
4use crate::io::home_directory;
5use crate::io::{read_file, ApiResult};
6use crate::prelude::PathBuf;
7use crate::schema::agent::ModelDetails;
8#[cfg(not(target_os = "windows"))]
9use crate::util::constants::app::DEFAULT_GOOSE_CONFIG_PATH;
10#[cfg(target_os = "windows")]
11use crate::util::constants::app::DEFAULT_GOOSE_WINDOWS_CONFIG_PATH;
12use alloc::string::{String, ToString};
13use color_eyre::eyre::eyre;
14#[cfg(target_os = "windows")]
15use directories::BaseDirs;
16use serde::{Deserialize, Serialize};
17use serde_norway::Value;
18use serde_with::skip_serializing_none;
19use validator::Validate;
20
21#[skip_serializing_none]
23#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
24#[serde(rename_all = "camelCase")]
25pub struct Config {
26 #[serde(skip_serializing)]
28 #[validate(length(min = 1))]
29 pub path: Option<String>,
30 #[serde(default = "default_host")]
32 #[validate(url)]
33 pub host: String,
34 #[serde(default = "default_base_path")]
36 #[validate(length(min = 1))]
37 pub base_path: String,
38 #[validate(length(min = 1))]
40 pub default_model: Option<String>,
41}
42impl Default for Config {
43 fn default() -> Self {
44 Self {
45 path: None,
46 host: default_host(),
47 base_path: default_base_path(),
48 default_model: None,
49 }
50 }
51}
52impl SyncTarget for Config {
53 const COMMAND: &'static str = "goose";
54 fn merge(self, overrides: Self) -> Self {
55 Self {
56 path: overrides.path.or(self.path),
57 host: overrides.host,
58 base_path: overrides.base_path,
59 default_model: overrides.default_model.or(self.default_model),
60 }
61 }
62 fn merge_cli_overrides(self, overrides: Self) -> Self {
63 Self {
64 path: overrides.path.or(self.path),
65 ..self
66 }
67 }
68 fn resolve_path(explicit: Option<&str>) -> ApiResult<PathBuf> {
69 explicit.map(PathBuf::from).map_or_else(
70 || {
71 #[cfg(target_os = "windows")]
72 let path = BaseDirs::new()
73 .map(|directories| directories.config_dir().join(DEFAULT_GOOSE_WINDOWS_CONFIG_PATH))
74 .ok_or_else(|| eyre!("Failed to resolve platform configuration directory"));
75 #[cfg(not(target_os = "windows"))]
76 let path = home_directory(DEFAULT_GOOSE_CONFIG_PATH);
77 path
78 },
79 Ok,
80 )
81 }
82 fn render(&self, options: Options<'_>) -> ApiResult<RenderedOutput> {
83 Self::resolve_path(self.path.as_deref()).and_then(|path| {
84 path.is_file()
85 .then(|| read_file(path.clone()))
86 .transpose()
87 .map(|content| content.unwrap_or_default())
88 .and_then(|before| {
89 match before.is_empty() {
90 | true => Ok(Value::Mapping(Default::default())),
91 | false => serde_norway::from_str(&before).map_err(|why| eyre!("Failed to parse existing Goose config: {why}")),
92 }
93 .and_then(|existing| self.upsert(existing, options.models))
94 .and_then(|updated| serde_norway::to_string(&updated).map_err(|why| eyre!("Failed to serialize Goose config: {why}")))
95 .map(|content| RenderedOutput {
96 target: "Goose",
97 path,
98 before,
99 content,
100 })
101 })
102 })
103 }
104}
105impl Config {
106 pub fn upsert(&self, existing: Value, models: &[ModelDetails]) -> ApiResult<Value> {
108 self.default_model
109 .as_ref()
110 .or_else(|| models.iter().find_map(|model| model.id.as_ref()))
111 .ok_or_else(|| eyre!("Goose synchronization requires at least one model"))
112 .and_then(|model| {
113 match existing {
114 | Value::Mapping(root) => Ok(root),
115 | _ => Err(eyre!("Goose configuration must be a YAML mapping")),
116 }
117 .map(|mut root| {
118 let providers_key = Value::String("providers".to_string());
119 let mut providers = root
120 .remove(&providers_key)
121 .and_then(|value| value.as_mapping().cloned())
122 .unwrap_or_default();
123 let openai_key = Value::String("openai".to_string());
124 let mut openai = providers
125 .remove(&openai_key)
126 .and_then(|value| value.as_mapping().cloned())
127 .unwrap_or_default();
128 openai.insert(Value::String("enabled".to_string()), Value::Bool(true));
129 openai.insert(Value::String("model".to_string()), Value::String(model.clone()));
130 openai.insert(Value::String("configured".to_string()), Value::Bool(true));
131 providers.insert(openai_key, Value::Mapping(openai));
132 root.insert(Value::String("active_provider".to_string()), Value::String("openai".to_string()));
133 root.insert(providers_key, Value::Mapping(providers));
134 root.insert(Value::String("OPENAI_HOST".to_string()), Value::String(self.host.clone()));
135 root.insert(Value::String("OPENAI_BASE_PATH".to_string()), Value::String(self.base_path.clone()));
136 Value::Mapping(root)
137 })
138 })
139 }
140}
141fn default_host() -> String {
142 "http://localhost:8080".to_string()
143}
144fn default_base_path() -> String {
145 "v1/chat/completions".to_string()
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn test_upsert_preserves_unrelated_settings_and_provider_fields() {
154 let existing: Value =
155 serde_norway::from_str("GOOSE_MODE: approve\nproviders:\n anthropic:\n enabled: true\n openai:\n custom: retained\n").unwrap();
156 let models = [ModelDetails::init().id("qwen").build()];
157 let updated = Config::default().upsert(existing, &models).unwrap();
158 let rendered = serde_norway::to_string(&updated).unwrap();
159 assert!(rendered.contains("GOOSE_MODE: approve"));
160 assert!(rendered.contains("anthropic:"));
161 assert!(rendered.contains("custom: retained"));
162 assert!(rendered.contains("model: qwen"));
163 assert!(rendered.contains("OPENAI_BASE_PATH: v1/chat/completions"));
164 }
165}