opencode_provider_manager/app/
actions.rs1use super::error::Result;
4use super::state::AppState;
5use crate::config_core::{ConfigLayer, OpenCodeConfig, ProviderConfig, merge::Mergeable};
6use std::collections::HashMap;
7
8impl AppState {
10 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 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 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 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 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 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 pub fn copy_provider_to_global(&mut self, provider_id: &str) -> Result<()> {
129 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 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.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 pub fn save(&mut self, layer: ConfigLayer) -> Result<()> {
166 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 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!(models.contains_key("gpt-4o"), "project model should be present");
330 assert!(
331 models.contains_key("gpt-3.5-turbo"),
332 "global-only model should be preserved"
333 );
334 }
335
336 #[test]
337 fn test_copy_provider_to_global_missing() {
338 let mut state = make_state_with_project_provider();
339
340 let result = state.copy_provider_to_global("nonexistent");
341 assert!(result.is_err());
342 }
343
344 #[test]
345 fn test_per_layer_dirty_tracking() {
346 let mut state = make_state_with_project_provider();
347 assert!(!state.dirty);
348
349 state.copy_provider_to_global("openai").unwrap();
351 assert!(state.dirty);
352
353 state.mark_clean(ConfigLayer::Global);
355 assert!(!state.dirty);
356 }
357
358 #[test]
359 fn test_per_layer_dirty_independent() {
360 let mut state = make_state_with_project_provider();
361
362 let provider = ProviderConfig {
364 npm: Some("test-sdk".to_string()),
365 ..Default::default()
366 };
367 state
368 .add_provider("test".to_string(), provider, ConfigLayer::Project)
369 .unwrap();
370 assert!(state.dirty);
371
372 state.copy_provider_to_global("openai").unwrap();
374
375 state.mark_clean(ConfigLayer::Global);
377 assert!(state.dirty, "project edits should still be dirty after saving global");
378 }
379
380 #[test]
381 fn test_add_model_fails_when_provider_missing() {
382 let mut state = make_state_with_project_provider();
383 let result = state.add_model(
385 "nonexistent",
386 "model-x".to_string(),
387 crate::config_core::ModelConfig::default(),
388 ConfigLayer::Project,
389 );
390 assert!(result.is_err());
391 }
392}