Skip to main content

acorn/io/sync/
goose.rs

1//! Goose CLI YAML synchronization configuration types
2use 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/// Configuration for synchronizing a local OpenAI-compatible model into Goose CLI.
22#[skip_serializing_none]
23#[derive(Clone, Debug, Deserialize, Serialize, Validate)]
24#[serde(rename_all = "camelCase")]
25pub struct Config {
26    /// Path to Goose's `config.yaml` on disk.
27    #[serde(skip_serializing)]
28    #[validate(length(min = 1))]
29    pub path: Option<String>,
30    /// OpenAI-compatible endpoint origin.
31    #[serde(default = "default_host")]
32    #[validate(url)]
33    pub host: String,
34    /// Chat completions path appended to the endpoint origin.
35    #[serde(default = "default_base_path")]
36    #[validate(length(min = 1))]
37    pub base_path: String,
38    /// Model to activate; defaults to the first synchronized model.
39    #[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    /// Upsert the active OpenAI provider while preserving unrelated Goose settings.
107    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}