opencode-provider-manager 0.1.7-beta.1

TUI/CLI binary crate for managing OpenCode provider configs
Documentation
//! Application state management.

use crate::config_core::{ConfigLayer, ConfigPaths, OpenCodeConfig, ProviderConfig};

/// The current UI state.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppMode {
    /// Viewing the merged config.
    MergedView,
    /// Viewing global and project side by side.
    SplitView,
    /// Adding a new provider (wizard).
    AddProvider,
    /// Editing an existing provider.
    EditProvider { provider_id: String },
    /// Selecting models for a provider.
    ModelSelector { provider_id: String },
    /// Viewing auth status.
    AuthStatus,
    /// Importing config.
    Import,
}

/// The application state.
#[derive(Debug)]
pub struct AppState {
    /// Loaded global config.
    pub global_config: Option<OpenCodeConfig>,
    /// Loaded project config.
    pub project_config: Option<OpenCodeConfig>,
    /// Loaded custom config (from --config / OPENCODE_CONFIG).
    pub custom_config: Option<OpenCodeConfig>,
    /// Merged config (global + custom + project, per documented precedence).
    pub merged_config: OpenCodeConfig,
    /// Resolved config paths.
    pub paths: ConfigPaths,
    /// Current UI state.
    pub mode: AppMode,
    /// Whether any config layer has unsaved changes (OR of all dirty_* flags).
    pub dirty: bool,
    /// Per-layer unsaved-change tracking.
    dirty_global: bool,
    dirty_project: bool,
    dirty_custom: bool,
    /// Currently selected layer for edits.
    pub edit_layer: ConfigLayer,
}

impl AppState {
    /// Create a new app state by discovering config paths.
    pub fn new() -> crate::config_core::Result<Self> {
        let paths = ConfigPaths::discover()?;
        Ok(Self {
            global_config: None,
            project_config: None,
            custom_config: None,
            merged_config: OpenCodeConfig::default(),
            paths,
            mode: AppMode::MergedView,
            dirty: false,
            dirty_global: false,
            dirty_project: false,
            dirty_custom: false,
            edit_layer: ConfigLayer::Project,
        })
    }

    /// Load all config layers and merge them.
    ///
    /// Merge order follows the documented OpenCode precedence:
    /// global < custom (OPENCODE_CONFIG) < project.
    pub fn load_configs(&mut self) -> crate::config_core::Result<()> {
        // Load global config
        self.global_config = if self.paths.global.exists() {
            Some(crate::config_core::jsonc::read_config(&self.paths.global)?)
        } else {
            None
        };

        // Load custom config (from --config or OPENCODE_CONFIG)
        self.custom_config = if let Some(ref custom_path) = self.paths.custom {
            if custom_path.exists() {
                Some(crate::config_core::jsonc::read_config(custom_path)?)
            } else {
                None
            }
        } else {
            None
        };

        // Load project config
        self.project_config = if let Some(ref project_path) = self.paths.project {
            if project_path.exists() {
                Some(crate::config_core::jsonc::read_config(project_path)?)
            } else {
                None
            }
        } else {
            None
        };

        // Merge in documented order: global < custom < project
        let mut configs_to_merge = Vec::new();
        if let Some(global) = &self.global_config {
            configs_to_merge.push(global.clone());
        }
        if let Some(custom) = &self.custom_config {
            configs_to_merge.push(custom.clone());
        }
        if let Some(project) = &self.project_config {
            configs_to_merge.push(project.clone());
        }

        self.merged_config = crate::config_core::merge_configs(&configs_to_merge);
        self.dirty = false;
        self.dirty_global = false;
        self.dirty_project = false;
        self.dirty_custom = false;

        Ok(())
    }

    /// Get a provider from the merged config.
    pub fn get_provider(&self, provider_id: &str) -> Option<&ProviderConfig> {
        self.merged_config.provider.as_ref()?.get(provider_id)
    }

    /// Get the list of configured provider IDs.
    pub fn provider_ids(&self) -> Vec<String> {
        self.merged_config
            .provider
            .as_ref()
            .map(|p| p.keys().cloned().collect())
            .unwrap_or_default()
    }

    /// Mark a specific config layer as having unsaved changes.
    pub fn mark_dirty(&mut self, layer: crate::config_core::ConfigLayer) {
        match layer {
            crate::config_core::ConfigLayer::Global => self.dirty_global = true,
            crate::config_core::ConfigLayer::Project => self.dirty_project = true,
            crate::config_core::ConfigLayer::Custom => self.dirty_custom = true,
        }
        self.dirty = true;
    }

    /// Mark a specific config layer as clean (saved). Recomputes the overall dirty flag.
    pub fn mark_clean(&mut self, layer: crate::config_core::ConfigLayer) {
        match layer {
            crate::config_core::ConfigLayer::Global => self.dirty_global = false,
            crate::config_core::ConfigLayer::Project => self.dirty_project = false,
            crate::config_core::ConfigLayer::Custom => self.dirty_custom = false,
        }
        self.dirty = self.dirty_global || self.dirty_project || self.dirty_custom;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_app_state_creation() {
        let state = AppState::new();
        assert!(state.is_ok());
        let state = state.unwrap();
        assert_eq!(state.mode, AppMode::MergedView);
        assert!(!state.dirty);
    }

    #[test]
    fn test_app_state_default_mode() {
        let state = AppState::new().unwrap();
        assert!(matches!(state.mode, AppMode::MergedView));
    }

    #[test]
    fn test_provider_ids_empty() {
        let state = AppState::new().unwrap();
        assert!(state.provider_ids().is_empty());
    }
}