Skip to main content

rusty_commit/config/
format.rs

1use anyhow::{Context, Result};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use super::Config;
6
7/// Configuration file format
8#[derive(Debug, Clone, Copy, PartialEq)]
9pub enum ConfigFormat {
10    Toml,
11    Json,
12}
13
14impl ConfigFormat {
15    /// Detect format from file extension
16    pub fn from_path(path: &Path) -> Self {
17        match path.extension().and_then(|s| s.to_str()) {
18            Some("toml") => ConfigFormat::Toml,
19            Some("json") => ConfigFormat::Json,
20            _ => ConfigFormat::Toml, // Default to TOML
21        }
22    }
23
24    /// Parse config from string based on format
25    pub fn parse(&self, contents: &str) -> Result<Config> {
26        match self {
27            ConfigFormat::Toml => toml::from_str(contents).context("Failed to parse TOML config"),
28            ConfigFormat::Json => {
29                serde_json::from_str(contents).context("Failed to parse JSON config")
30            }
31        }
32    }
33
34    /// Serialize config to string based on format
35    pub fn serialize(&self, config: &Config) -> Result<String> {
36        match self {
37            ConfigFormat::Toml => {
38                toml::to_string_pretty(config).context("Failed to serialize to TOML")
39            }
40            ConfigFormat::Json => {
41                serde_json::to_string_pretty(config).context("Failed to serialize to JSON")
42            }
43        }
44    }
45}
46
47/// Configuration locations with priority
48#[derive(Debug)]
49pub struct ConfigLocations {
50    /// Repository-specific config (highest priority)
51    pub repo: Option<PathBuf>,
52    /// Global config
53    pub global: PathBuf,
54}
55
56impl ConfigLocations {
57    /// Get all config locations to check
58    pub fn get() -> Result<Self> {
59        // Global config locations (in priority order)
60        let global = if let Ok(config_home) = std::env::var("RCO_CONFIG_HOME") {
61            PathBuf::from(config_home).join("config.toml")
62        } else {
63            let home = dirs::home_dir().context("Could not find home directory")?;
64            home.join(".config").join("rustycommit").join("config.toml")
65        };
66
67        // Respect an opt-out for repo-level config (useful for tests/CI isolation)
68        let ignore_repo_config = std::env::var("RCO_IGNORE_REPO_CONFIG")
69            .ok()
70            .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
71            .unwrap_or(false);
72
73        // Repository-specific config (if in a git repo and not ignored)
74        let repo = if ignore_repo_config {
75            None
76        } else if let Ok(repo) = git2::Repository::open_from_env() {
77            let workdir = repo
78                .workdir()
79                .context("Could not get repository working directory")?;
80
81            // Check for multiple possible config file names
82            let possible_configs = [
83                workdir.join(".rustycommit.toml"),
84                workdir.join(".rustycommit.json"),
85                workdir.join(".rco.toml"),
86                workdir.join(".rco.json"),
87            ];
88
89            possible_configs.into_iter().find(|p| p.exists())
90        } else {
91            None
92        };
93
94        Ok(ConfigLocations { repo, global })
95    }
96
97    /// Load config with proper priority: repo > global > default
98    pub fn load_merged() -> Result<Config> {
99        let locations = Self::get()?;
100
101        // Start with default config
102        let mut config = Config::default();
103
104        // Load global config if exists
105        if locations.global.exists() {
106            if let Ok(contents) = fs::read_to_string(&locations.global) {
107                let format = ConfigFormat::from_path(&locations.global);
108                match format.parse(&contents) {
109                    Ok(global_config) => config.merge(global_config),
110                    Err(e) => tracing::warn!(
111                        "Failed to parse global config at {}: {}",
112                        locations.global.display(),
113                        e
114                    ),
115                }
116            }
117        }
118
119        // Load repo-specific config if exists (highest priority)
120        if let Some(repo_path) = &locations.repo {
121            if let Ok(contents) = fs::read_to_string(repo_path) {
122                let format = ConfigFormat::from_path(repo_path);
123                match format.parse(&contents) {
124                    Ok(repo_config) => config.merge(repo_config),
125                    Err(e) => tracing::warn!(
126                        "Failed to parse repo config at {}: {}",
127                        repo_path.display(),
128                        e
129                    ),
130                }
131            }
132        }
133
134        // Load values from environment variables (RCO_ prefix)
135        config.load_from_environment();
136
137        // Try to load API key from secure storage if not in file or env
138        if config.api_key.is_none() {
139            if let Ok(Some(key)) = crate::config::secure_storage::get_secret("RCO_API_KEY") {
140                config.api_key = Some(key);
141            }
142        }
143
144        // Also check for OAuth tokens
145        if config.api_key.is_none() {
146            if let Some(_token) = crate::auth::token_storage::get_access_token()
147                .ok()
148                .flatten()
149            {
150                // Token is handled separately in auth module, but we can set a flag
151                // to indicate OAuth is available
152            }
153        }
154
155        Ok(config)
156    }
157
158    /// Save config to appropriate location
159    pub fn save(config: &Config, location: ConfigLocation) -> Result<()> {
160        let locations = Self::get()?;
161
162        let (path, format) = match location {
163            ConfigLocation::Global => {
164                // Ensure directory exists
165                if let Some(parent) = locations.global.parent() {
166                    fs::create_dir_all(parent)?;
167                }
168                (locations.global, ConfigFormat::Toml)
169            }
170            ConfigLocation::Repo => {
171                // Use existing repo config or create new one
172                let path = locations.repo.unwrap_or_else(|| {
173                    if let Ok(repo) = git2::Repository::open_from_env() {
174                        if let Some(workdir) = repo.workdir() {
175                            return workdir.join(".rustycommit.toml");
176                        }
177                    }
178                    PathBuf::from(".rustycommit.toml")
179                });
180                let format = ConfigFormat::from_path(&path);
181                (path, format)
182            }
183        };
184
185        let contents = format.serialize(config)?;
186        fs::write(&path, contents).context("Failed to write config file")?;
187
188        Ok(())
189    }
190}
191
192/// Where to save configuration
193#[derive(Debug, Clone, Copy)]
194pub enum ConfigLocation {
195    Global,
196    #[allow(dead_code)]
197    Repo,
198}