Skip to main content

opencode_provider_manager/app/
state.rs

1//! Application state management.
2
3use crate::config_core::{ConfigLayer, ConfigPaths, OpenCodeConfig, ProviderConfig};
4
5/// The current UI state.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum AppMode {
8    /// Viewing the merged config.
9    MergedView,
10    /// Viewing global and project side by side.
11    SplitView,
12    /// Adding a new provider (wizard).
13    AddProvider,
14    /// Editing an existing provider.
15    EditProvider { provider_id: String },
16    /// Selecting models for a provider.
17    ModelSelector { provider_id: String },
18    /// Viewing auth status.
19    AuthStatus,
20    /// Importing config.
21    Import,
22}
23
24/// The application state.
25#[derive(Debug)]
26pub struct AppState {
27    /// Loaded global config.
28    pub global_config: Option<OpenCodeConfig>,
29    /// Loaded project config.
30    pub project_config: Option<OpenCodeConfig>,
31    /// Loaded custom config (from --config / OPENCODE_CONFIG).
32    pub custom_config: Option<OpenCodeConfig>,
33    /// Merged config (global + custom + project, per documented precedence).
34    pub merged_config: OpenCodeConfig,
35    /// Resolved config paths.
36    pub paths: ConfigPaths,
37    /// Current UI state.
38    pub mode: AppMode,
39    /// Whether any config layer has unsaved changes (OR of all dirty_* flags).
40    pub dirty: bool,
41    /// Per-layer unsaved-change tracking.
42    dirty_global: bool,
43    dirty_project: bool,
44    dirty_custom: bool,
45    /// Currently selected layer for edits.
46    pub edit_layer: ConfigLayer,
47}
48
49impl AppState {
50    /// Create a new app state by discovering config paths.
51    pub fn new() -> crate::config_core::Result<Self> {
52        let paths = ConfigPaths::discover()?;
53        Ok(Self {
54            global_config: None,
55            project_config: None,
56            custom_config: None,
57            merged_config: OpenCodeConfig::default(),
58            paths,
59            mode: AppMode::MergedView,
60            dirty: false,
61            dirty_global: false,
62            dirty_project: false,
63            dirty_custom: false,
64            edit_layer: ConfigLayer::Project,
65        })
66    }
67
68    /// Load all config layers and merge them.
69    ///
70    /// Merge order follows the documented OpenCode precedence:
71    /// global < custom (OPENCODE_CONFIG) < project.
72    pub fn load_configs(&mut self) -> crate::config_core::Result<()> {
73        // Load global config
74        self.global_config = if self.paths.global.exists() {
75            Some(crate::config_core::jsonc::read_config(&self.paths.global)?)
76        } else {
77            None
78        };
79
80        // Load custom config (from --config or OPENCODE_CONFIG)
81        self.custom_config = if let Some(ref custom_path) = self.paths.custom {
82            if custom_path.exists() {
83                Some(crate::config_core::jsonc::read_config(custom_path)?)
84            } else {
85                None
86            }
87        } else {
88            None
89        };
90
91        // Load project config
92        self.project_config = if let Some(ref project_path) = self.paths.project {
93            if project_path.exists() {
94                Some(crate::config_core::jsonc::read_config(project_path)?)
95            } else {
96                None
97            }
98        } else {
99            None
100        };
101
102        // Merge in documented order: global < custom < project
103        let mut configs_to_merge = Vec::new();
104        if let Some(global) = &self.global_config {
105            configs_to_merge.push(global.clone());
106        }
107        if let Some(custom) = &self.custom_config {
108            configs_to_merge.push(custom.clone());
109        }
110        if let Some(project) = &self.project_config {
111            configs_to_merge.push(project.clone());
112        }
113
114        self.merged_config = crate::config_core::merge_configs(&configs_to_merge);
115        self.dirty = false;
116        self.dirty_global = false;
117        self.dirty_project = false;
118        self.dirty_custom = false;
119
120        Ok(())
121    }
122
123    /// Get a provider from the merged config.
124    pub fn get_provider(&self, provider_id: &str) -> Option<&ProviderConfig> {
125        self.merged_config.provider.as_ref()?.get(provider_id)
126    }
127
128    /// Get the list of configured provider IDs.
129    pub fn provider_ids(&self) -> Vec<String> {
130        self.merged_config
131            .provider
132            .as_ref()
133            .map(|p| p.keys().cloned().collect())
134            .unwrap_or_default()
135    }
136
137    /// Get provider IDs from a specific config layer.
138    pub fn provider_ids_for_layer(&self, layer: crate::config_core::ConfigLayer) -> Vec<String> {
139        let config = match layer {
140            crate::config_core::ConfigLayer::Global => self.global_config.as_ref(),
141            crate::config_core::ConfigLayer::Project => self.project_config.as_ref(),
142            crate::config_core::ConfigLayer::Custom => self.custom_config.as_ref(),
143        };
144        config
145            .and_then(|c| c.provider.as_ref())
146            .map(|p| p.keys().cloned().collect())
147            .unwrap_or_default()
148    }
149
150    /// Mark a specific config layer as having unsaved changes.
151    pub fn mark_dirty(&mut self, layer: crate::config_core::ConfigLayer) {
152        match layer {
153            crate::config_core::ConfigLayer::Global => self.dirty_global = true,
154            crate::config_core::ConfigLayer::Project => self.dirty_project = true,
155            crate::config_core::ConfigLayer::Custom => self.dirty_custom = true,
156        }
157        self.dirty = true;
158    }
159
160    /// Mark a specific config layer as clean (saved). Recomputes the overall dirty flag.
161    pub fn mark_clean(&mut self, layer: crate::config_core::ConfigLayer) {
162        match layer {
163            crate::config_core::ConfigLayer::Global => self.dirty_global = false,
164            crate::config_core::ConfigLayer::Project => self.dirty_project = false,
165            crate::config_core::ConfigLayer::Custom => self.dirty_custom = false,
166        }
167        self.dirty = self.dirty_global || self.dirty_project || self.dirty_custom;
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_app_state_creation() {
177        let state = AppState::new();
178        assert!(state.is_ok());
179        let state = state.unwrap();
180        assert_eq!(state.mode, AppMode::MergedView);
181        assert!(!state.dirty);
182    }
183
184    #[test]
185    fn test_app_state_default_mode() {
186        let state = AppState::new().unwrap();
187        assert!(matches!(state.mode, AppMode::MergedView));
188    }
189
190    #[test]
191    fn test_provider_ids_empty() {
192        let state = AppState::new().unwrap();
193        assert!(state.provider_ids().is_empty());
194    }
195}