Skip to main content

opencode_provider_manager/app/
actions.rs

1//! User actions that transform app state.
2
3use super::error::Result;
4use super::state::AppState;
5use crate::config_core::{ConfigLayer, OpenCodeConfig, ProviderConfig, merge::Mergeable};
6use std::collections::HashMap;
7
8/// Actions the user can perform on the app state.
9impl AppState {
10    /// Add a new provider to the config at the specified layer.
11    pub fn add_provider(
12        &mut self,
13        provider_id: String,
14        config: ProviderConfig,
15        layer: ConfigLayer,
16    ) -> Result<()> {
17        let target_config = self.config_for_layer_mut(layer)?;
18        target_config
19            .provider
20            .get_or_insert_with(HashMap::new)
21            .insert(provider_id.clone(), config);
22        self.recompute_merged();
23        self.mark_dirty(layer);
24        Ok(())
25    }
26
27    /// Remove a provider from the config at the specified layer.
28    pub fn remove_provider(&mut self, provider_id: &str, layer: ConfigLayer) -> Result<()> {
29        let target_config = self.config_for_layer_mut(layer)?;
30        if let Some(ref mut providers) = target_config.provider {
31            providers.remove(provider_id);
32        }
33        self.recompute_merged();
34        self.mark_dirty(layer);
35        Ok(())
36    }
37
38    /// Edit a provider field in the config at the specified layer.
39    pub fn edit_provider_field(
40        &mut self,
41        provider_id: &str,
42        field: &str,
43        value: serde_json::Value,
44        layer: ConfigLayer,
45    ) -> Result<()> {
46        let target_config = self.config_for_layer_mut(layer)?;
47        if let Some(ref mut providers) = target_config.provider {
48            if let Some(provider) = providers.get_mut(provider_id) {
49                match field {
50                    "name" => {
51                        if let serde_json::Value::String(s) = value {
52                            provider.name = Some(s);
53                        }
54                    }
55                    "npm" => {
56                        if let serde_json::Value::String(s) = value {
57                            provider.npm = Some(s);
58                        }
59                    }
60                    _ => {
61                        // Store as an option
62                        provider
63                            .options
64                            .get_or_insert_with(HashMap::new)
65                            .insert(field.to_string(), value);
66                    }
67                }
68            }
69        }
70        self.recompute_merged();
71        self.mark_dirty(layer);
72        Ok(())
73    }
74
75    /// Add a model to a provider.
76    pub fn add_model(
77        &mut self,
78        provider_id: &str,
79        model_id: String,
80        model_config: crate::config_core::ModelConfig,
81        layer: ConfigLayer,
82    ) -> Result<()> {
83        let target_config = self.config_for_layer_mut(layer)?;
84        let provider = target_config
85            .provider
86            .as_mut()
87            .and_then(|p| p.get_mut(provider_id))
88            .ok_or_else(|| {
89                super::error::AppError::State(format!(
90                    "Provider '{provider_id}' not found in {layer:?} config"
91                ))
92            })?;
93        provider
94            .models
95            .get_or_insert_with(HashMap::new)
96            .insert(model_id, model_config);
97        self.recompute_merged();
98        self.mark_dirty(layer);
99        Ok(())
100    }
101
102    /// Remove a model from a provider.
103    pub fn remove_model(
104        &mut self,
105        provider_id: &str,
106        model_id: &str,
107        layer: ConfigLayer,
108    ) -> Result<()> {
109        let target_config = self.config_for_layer_mut(layer)?;
110        if let Some(ref mut providers) = target_config.provider {
111            if let Some(provider) = providers.get_mut(provider_id) {
112                if let Some(ref mut models) = provider.models {
113                    models.remove(model_id);
114                }
115            }
116        }
117        self.recompute_merged();
118        self.mark_dirty(layer);
119        Ok(())
120    }
121
122    /// Copy a provider from the current edit_layer to the global config (merge if already present).
123    ///
124    /// The incoming provider (from edit_layer) is treated as higher priority: its
125    /// fields override the existing global entry for the same provider, but models
126    /// that only exist in global are preserved.  Only the Global layer is marked
127    /// dirty — edit_layer's dirty state is unchanged.
128    pub fn copy_provider_to_global(&mut self, provider_id: &str) -> Result<()> {
129        // Read source provider from edit_layer (not merged).
130        let source = self
131            .config_for_layer(self.edit_layer)?
132            .provider
133            .as_ref()
134            .and_then(|p| p.get(provider_id))
135            .cloned()
136            .ok_or_else(|| {
137                super::error::AppError::State(format!(
138                    "Provider '{provider_id}' not found in {:?} config",
139                    self.edit_layer
140                ))
141            })?;
142
143        // Merge into global config.
144        let global = self.config_for_layer_mut(ConfigLayer::Global)?;
145        let providers = global.provider.get_or_insert_with(HashMap::new);
146
147        let merged = if let Some(existing) = providers.remove(provider_id) {
148            // existing (global) is lower priority, source (edit_layer) is higher priority.
149            existing.merge(source)
150        } else {
151            source
152        };
153        providers.insert(provider_id.to_string(), merged);
154
155        self.recompute_merged();
156        self.mark_dirty(ConfigLayer::Global);
157        Ok(())
158    }
159
160    /// Save the config at the specified layer to disk.
161    ///
162    /// For the Project layer, falls back to `./opencode.json` in the current
163    /// directory when no project file was discovered, so that new project
164    /// configs can be created.
165    pub fn save(&mut self, layer: ConfigLayer) -> Result<()> {
166        // Resolve save path. For Project, fall back to ./opencode.json if none
167        // was discovered so first-time project config creation works.
168        let path_buf = match layer {
169            ConfigLayer::Project => match self.paths.project.clone() {
170                Some(p) => p,
171                None => {
172                    let cwd = std::env::current_dir().map_err(|e| {
173                        super::error::AppError::State(format!("Cannot read cwd: {e}"))
174                    })?;
175                    let fallback = cwd.join("opencode.json");
176                    self.paths.project = Some(fallback.clone());
177                    fallback
178                }
179            },
180            other => self.paths.path_for_layer(other).cloned().ok_or_else(|| {
181                super::error::AppError::State(format!("No config path for layer {other:?}"))
182            })?,
183        };
184
185        let config = self.config_for_layer(layer)?;
186        crate::config_core::jsonc::write_config(config, &path_buf)?;
187        self.mark_clean(layer);
188        Ok(())
189    }
190
191    // --- Private helpers ---
192
193    fn config_for_layer(&self, layer: ConfigLayer) -> Result<&OpenCodeConfig> {
194        match layer {
195            ConfigLayer::Global => self.global_config.as_ref().ok_or_else(|| {
196                super::error::AppError::State("No global config loaded".to_string())
197            }),
198            ConfigLayer::Project => self.project_config.as_ref().ok_or_else(|| {
199                super::error::AppError::State("No project config loaded".to_string())
200            }),
201            ConfigLayer::Custom => self.custom_config.as_ref().ok_or_else(|| {
202                super::error::AppError::State("No custom config loaded".to_string())
203            }),
204        }
205    }
206
207    fn config_for_layer_mut(&mut self, layer: ConfigLayer) -> Result<&mut OpenCodeConfig> {
208        match layer {
209            ConfigLayer::Global => {
210                if self.global_config.is_none() {
211                    self.global_config = Some(OpenCodeConfig::default());
212                }
213                Ok(self.global_config.as_mut().unwrap())
214            }
215            ConfigLayer::Project => {
216                if self.project_config.is_none() {
217                    self.project_config = Some(OpenCodeConfig::default());
218                }
219                Ok(self.project_config.as_mut().unwrap())
220            }
221            ConfigLayer::Custom => {
222                if self.custom_config.is_none() {
223                    self.custom_config = Some(OpenCodeConfig::default());
224                }
225                Ok(self.custom_config.as_mut().unwrap())
226            }
227        }
228    }
229
230    pub fn recompute_merged(&mut self) {
231        let mut configs = Vec::new();
232        if let Some(global) = &self.global_config {
233            configs.push(global.clone());
234        }
235        if let Some(custom) = &self.custom_config {
236            configs.push(custom.clone());
237        }
238        if let Some(project) = &self.project_config {
239            configs.push(project.clone());
240        }
241        self.merged_config = crate::config_core::merge_configs(&configs);
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::config_core::{ConfigLayer, ModelLimit};
249
250    fn make_state_with_project_provider() -> AppState {
251        let mut state = AppState::new().unwrap();
252        state.edit_layer = ConfigLayer::Project;
253
254        let mut project_models = HashMap::new();
255        project_models.insert(
256            "gpt-4o".to_string(),
257            crate::config_core::ModelConfig {
258                name: Some("GPT-4o".to_string()),
259                limit: Some(ModelLimit {
260                    context: Some(128_000),
261                    output: None,
262                }),
263                ..Default::default()
264            },
265        );
266        let project_provider = ProviderConfig {
267            npm: Some("@ai-sdk/openai".to_string()),
268            name: Some("OpenAI".to_string()),
269            models: Some(project_models),
270            ..Default::default()
271        };
272        state.project_config = Some(OpenCodeConfig::default());
273        state
274            .project_config
275            .as_mut()
276            .unwrap()
277            .provider
278            .get_or_insert_with(HashMap::new)
279            .insert("openai".to_string(), project_provider);
280        state.recompute_merged();
281        state
282    }
283
284    #[test]
285    fn test_copy_provider_to_global_new() {
286        let mut state = make_state_with_project_provider();
287
288        assert!(state.global_config.is_none());
289
290        state.copy_provider_to_global("openai").unwrap();
291
292        let global = state.global_config.as_ref().unwrap();
293        let provider = global.provider.as_ref().unwrap().get("openai").unwrap();
294        assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai"));
295        assert!(state.dirty);
296    }
297
298    #[test]
299    fn test_copy_provider_to_global_merge() {
300        let mut state = make_state_with_project_provider();
301
302        let mut global_models = HashMap::new();
303        global_models.insert(
304            "gpt-3.5-turbo".to_string(),
305            crate::config_core::ModelConfig::default(),
306        );
307        let global_provider = ProviderConfig {
308            npm: Some("old-sdk".to_string()),
309            name: Some("Old Name".to_string()),
310            models: Some(global_models),
311            ..Default::default()
312        };
313        state.global_config = Some(OpenCodeConfig::default());
314        state
315            .global_config
316            .as_mut()
317            .unwrap()
318            .provider
319            .get_or_insert_with(HashMap::new)
320            .insert("openai".to_string(), global_provider);
321
322        state.copy_provider_to_global("openai").unwrap();
323
324        let global = state.global_config.as_ref().unwrap();
325        let provider = global.provider.as_ref().unwrap().get("openai").unwrap();
326        assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai"));
327        assert_eq!(provider.name.as_deref(), Some("OpenAI"));
328        let models = provider.models.as_ref().unwrap();
329        assert!(
330            models.contains_key("gpt-4o"),
331            "project model should be present"
332        );
333        assert!(
334            models.contains_key("gpt-3.5-turbo"),
335            "global-only model should be preserved"
336        );
337    }
338
339    #[test]
340    fn test_copy_provider_to_global_missing() {
341        let mut state = make_state_with_project_provider();
342
343        let result = state.copy_provider_to_global("nonexistent");
344        assert!(result.is_err());
345    }
346
347    #[test]
348    fn test_per_layer_dirty_tracking() {
349        let mut state = make_state_with_project_provider();
350        assert!(!state.dirty);
351
352        // Copy to global marks global dirty
353        state.copy_provider_to_global("openai").unwrap();
354        assert!(state.dirty);
355
356        // mark_clean for Global should clear dirty when no other layer is dirty
357        state.mark_clean(ConfigLayer::Global);
358        assert!(!state.dirty);
359    }
360
361    #[test]
362    fn test_per_layer_dirty_independent() {
363        let mut state = make_state_with_project_provider();
364
365        // Mark project dirty via add_provider
366        let provider = ProviderConfig {
367            npm: Some("test-sdk".to_string()),
368            ..Default::default()
369        };
370        state
371            .add_provider("test".to_string(), provider, ConfigLayer::Project)
372            .unwrap();
373        assert!(state.dirty);
374
375        // Copy to global also marks global dirty
376        state.copy_provider_to_global("openai").unwrap();
377
378        // Saving global clears global dirty, but project remains dirty
379        state.mark_clean(ConfigLayer::Global);
380        assert!(
381            state.dirty,
382            "project edits should still be dirty after saving global"
383        );
384    }
385
386    #[test]
387    fn test_add_model_fails_when_provider_missing() {
388        let mut state = make_state_with_project_provider();
389        // "nonexistent" provider is not in project_config
390        let result = state.add_model(
391            "nonexistent",
392            "model-x".to_string(),
393            crate::config_core::ModelConfig::default(),
394            ConfigLayer::Project,
395        );
396        assert!(result.is_err());
397    }
398}