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    /// Mark a specific config layer as having unsaved changes.
138    pub fn mark_dirty(&mut self, layer: crate::config_core::ConfigLayer) {
139        match layer {
140            crate::config_core::ConfigLayer::Global => self.dirty_global = true,
141            crate::config_core::ConfigLayer::Project => self.dirty_project = true,
142            crate::config_core::ConfigLayer::Custom => self.dirty_custom = true,
143        }
144        self.dirty = true;
145    }
146
147    /// Mark a specific config layer as clean (saved). Recomputes the overall dirty flag.
148    pub fn mark_clean(&mut self, layer: crate::config_core::ConfigLayer) {
149        match layer {
150            crate::config_core::ConfigLayer::Global => self.dirty_global = false,
151            crate::config_core::ConfigLayer::Project => self.dirty_project = false,
152            crate::config_core::ConfigLayer::Custom => self.dirty_custom = false,
153        }
154        self.dirty = self.dirty_global || self.dirty_project || self.dirty_custom;
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_app_state_creation() {
164        let state = AppState::new();
165        assert!(state.is_ok());
166        let state = state.unwrap();
167        assert_eq!(state.mode, AppMode::MergedView);
168        assert!(!state.dirty);
169    }
170
171    #[test]
172    fn test_app_state_default_mode() {
173        let state = AppState::new().unwrap();
174        assert!(matches!(state.mode, AppMode::MergedView));
175    }
176
177    #[test]
178    fn test_provider_ids_empty() {
179        let state = AppState::new().unwrap();
180        assert!(state.provider_ids().is_empty());
181    }
182}