bitbucket_cli/config/
settings.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::PathBuf;
5
6const APP_NAME: &str = "bitbucket-cli";
7const CONFIG_FILE: &str = "config.toml";
8
9/// XDG Base Directory helper functions
10/// 
11/// On Linux, these follow the XDG Base Directory Specification:
12/// - Config: `$XDG_CONFIG_HOME/bitbucket-cli` (default: `~/.config/bitbucket-cli`)
13/// - Data: `$XDG_DATA_HOME/bitbucket-cli` (default: `~/.local/share/bitbucket-cli`)
14/// - Cache: `$XDG_CACHE_HOME/bitbucket-cli` (default: `~/.cache/bitbucket-cli`)
15/// - State: `$XDG_STATE_HOME/bitbucket-cli` (default: `~/.local/state/bitbucket-cli`)
16///
17/// On macOS:
18/// - Config: `~/Library/Application Support/bitbucket-cli`
19/// - Data: `~/Library/Application Support/bitbucket-cli`
20/// - Cache: `~/Library/Caches/bitbucket-cli`
21///
22/// On Windows:
23/// - Config: `%APPDATA%\bitbucket-cli`
24/// - Data: `%APPDATA%\bitbucket-cli`
25/// - Cache: `%LOCALAPPDATA%\bitbucket-cli`
26pub mod xdg {
27    use super::*;
28
29    /// Get the XDG config directory for the application
30    /// 
31    /// Respects `$XDG_CONFIG_HOME` on Linux (falls back to `~/.config`)
32    pub fn config_dir() -> Result<PathBuf> {
33        // First check for explicit XDG_CONFIG_HOME on Unix
34        #[cfg(unix)]
35        if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
36            if !xdg_config.is_empty() {
37                return Ok(PathBuf::from(xdg_config).join(APP_NAME));
38            }
39        }
40        
41        dirs::config_dir()
42            .map(|p| p.join(APP_NAME))
43            .context("Could not determine config directory")
44    }
45
46    /// Get the XDG data directory for the application
47    /// 
48    /// Respects `$XDG_DATA_HOME` on Linux (falls back to `~/.local/share`)
49    pub fn data_dir() -> Result<PathBuf> {
50        #[cfg(unix)]
51        if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") {
52            if !xdg_data.is_empty() {
53                return Ok(PathBuf::from(xdg_data).join(APP_NAME));
54            }
55        }
56        
57        dirs::data_dir()
58            .map(|p| p.join(APP_NAME))
59            .context("Could not determine data directory")
60    }
61
62    /// Get the XDG cache directory for the application
63    /// 
64    /// Respects `$XDG_CACHE_HOME` on Linux (falls back to `~/.cache`)
65    pub fn cache_dir() -> Result<PathBuf> {
66        #[cfg(unix)]
67        if let Ok(xdg_cache) = std::env::var("XDG_CACHE_HOME") {
68            if !xdg_cache.is_empty() {
69                return Ok(PathBuf::from(xdg_cache).join(APP_NAME));
70            }
71        }
72        
73        dirs::cache_dir()
74            .map(|p| p.join(APP_NAME))
75            .context("Could not determine cache directory")
76    }
77
78    /// Get the XDG state directory for the application
79    /// 
80    /// Respects `$XDG_STATE_HOME` on Linux (falls back to `~/.local/state`)
81    /// On non-Linux platforms, falls back to data directory
82    pub fn state_dir() -> Result<PathBuf> {
83        #[cfg(unix)]
84        if let Ok(xdg_state) = std::env::var("XDG_STATE_HOME") {
85            if !xdg_state.is_empty() {
86                return Ok(PathBuf::from(xdg_state).join(APP_NAME));
87            }
88        }
89        
90        // dirs::state_dir() is available on Linux, falls back to data_dir on other platforms
91        dirs::state_dir()
92            .or_else(dirs::data_dir)
93            .map(|p| p.join(APP_NAME))
94            .context("Could not determine state directory")
95    }
96
97    /// Ensure a directory exists, creating it if necessary
98    pub fn ensure_dir(path: &PathBuf) -> Result<()> {
99        if !path.exists() {
100            fs::create_dir_all(path)
101                .with_context(|| format!("Failed to create directory: {:?}", path))?;
102        }
103        Ok(())
104    }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct Config {
109    #[serde(default)]
110    pub auth: AuthConfig,
111    #[serde(default)]
112    pub defaults: DefaultsConfig,
113    #[serde(default)]
114    pub display: DisplayConfig,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct AuthConfig {
119    pub username: Option<String>,
120    pub default_workspace: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct DefaultsConfig {
125    pub workspace: Option<String>,
126    pub repository: Option<String>,
127    pub branch: Option<String>,
128}
129
130impl Default for DefaultsConfig {
131    fn default() -> Self {
132        Self {
133            workspace: None,
134            repository: None,
135            branch: Some("main".to_string()),
136        }
137    }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct DisplayConfig {
142    pub color: bool,
143    pub pager: bool,
144    pub date_format: String,
145}
146
147impl Default for DisplayConfig {
148    fn default() -> Self {
149        Self {
150            color: true,
151            pager: true,
152            date_format: "%Y-%m-%d %H:%M".to_string(),
153        }
154    }
155}
156
157impl Config {
158    /// Get the configuration directory path (XDG compliant)
159    /// 
160    /// Returns `$XDG_CONFIG_HOME/bitbucket-cli` on Linux,
161    /// or platform-appropriate equivalent on other systems.
162    pub fn config_dir() -> Result<PathBuf> {
163        xdg::config_dir()
164    }
165
166    /// Get the configuration file path
167    pub fn config_path() -> Result<PathBuf> {
168        Ok(Self::config_dir()?.join(CONFIG_FILE))
169    }
170
171    /// Get the data directory path (XDG compliant)
172    /// 
173    /// Returns `$XDG_DATA_HOME/bitbucket-cli` on Linux.
174    /// Use this for persistent application data.
175    pub fn data_dir() -> Result<PathBuf> {
176        xdg::data_dir()
177    }
178
179    /// Get the cache directory path (XDG compliant)
180    /// 
181    /// Returns `$XDG_CACHE_HOME/bitbucket-cli` on Linux.
182    /// Use this for cached data that can be regenerated.
183    pub fn cache_dir() -> Result<PathBuf> {
184        xdg::cache_dir()
185    }
186
187    /// Get the state directory path (XDG compliant)
188    /// 
189    /// Returns `$XDG_STATE_HOME/bitbucket-cli` on Linux.
190    /// Use this for state data like logs and history.
191    pub fn state_dir() -> Result<PathBuf> {
192        xdg::state_dir()
193    }
194
195    /// Load configuration from file, or create default if it doesn't exist
196    pub fn load() -> Result<Self> {
197        let config_path = Self::config_path()?;
198
199        if !config_path.exists() {
200            return Ok(Self::default());
201        }
202
203        let contents = fs::read_to_string(&config_path)
204            .with_context(|| format!("Failed to read config file: {:?}", config_path))?;
205
206        let config: Config = toml::from_str(&contents)
207            .with_context(|| format!("Failed to parse config file: {:?}", config_path))?;
208
209        Ok(config)
210    }
211
212    /// Save configuration to file
213    pub fn save(&self) -> Result<()> {
214        let config_dir = Self::config_dir()?;
215        let config_path = Self::config_path()?;
216
217        // Create config directory if it doesn't exist (XDG compliant)
218        xdg::ensure_dir(&config_dir)?;
219
220        let contents = toml::to_string_pretty(self).context("Failed to serialize config")?;
221
222        fs::write(&config_path, contents)
223            .with_context(|| format!("Failed to write config file: {:?}", config_path))?;
224
225        Ok(())
226    }
227
228    /// Set the authenticated username
229    pub fn set_username(&mut self, username: &str) {
230        self.auth.username = Some(username.to_string());
231    }
232
233    /// Get the authenticated username
234    pub fn username(&self) -> Option<&str> {
235        self.auth.username.as_deref()
236    }
237
238    /// Set the default workspace
239    pub fn set_default_workspace(&mut self, workspace: &str) {
240        self.defaults.workspace = Some(workspace.to_string());
241    }
242
243    /// Get the default workspace
244    pub fn default_workspace(&self) -> Option<&str> {
245        self.defaults.workspace.as_deref()
246    }
247
248    /// Clear authentication settings (for logout)
249    pub fn clear_auth(&mut self) {
250        self.auth.username = None;
251        self.auth.default_workspace = None;
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn test_default_config() {
261        let config = Config::default();
262        assert!(config.auth.username.is_none());
263        assert!(config.display.color);
264    }
265
266    #[test]
267    fn test_config_serialization() {
268        let config = Config::default();
269        let serialized = toml::to_string(&config).unwrap();
270        let deserialized: Config = toml::from_str(&serialized).unwrap();
271        assert_eq!(config.display.color, deserialized.display.color);
272    }
273
274    #[test]
275    fn test_xdg_directories() {
276        // These should not panic and should return valid paths
277        let config_dir = xdg::config_dir().unwrap();
278        let data_dir = xdg::data_dir().unwrap();
279        let cache_dir = xdg::cache_dir().unwrap();
280        let state_dir = xdg::state_dir().unwrap();
281
282        // All paths should end with our app name
283        assert!(config_dir.ends_with("bitbucket-cli"));
284        assert!(data_dir.ends_with("bitbucket-cli"));
285        assert!(cache_dir.ends_with("bitbucket-cli"));
286        assert!(state_dir.ends_with("bitbucket-cli"));
287    }
288
289    #[test]
290    #[cfg(unix)]
291    fn test_xdg_env_override() {
292        use std::env;
293
294        // Save original values
295        let orig_config = env::var("XDG_CONFIG_HOME").ok();
296
297        // SAFETY: This test runs single-threaded and we restore the original value after
298        unsafe {
299            // Set custom XDG_CONFIG_HOME
300            env::set_var("XDG_CONFIG_HOME", "/tmp/test-xdg-config");
301        }
302
303        let config_dir = xdg::config_dir().unwrap();
304        assert_eq!(
305            config_dir,
306            PathBuf::from("/tmp/test-xdg-config/bitbucket-cli")
307        );
308
309        // SAFETY: Restoring original environment state
310        unsafe {
311            match orig_config {
312                Some(val) => env::set_var("XDG_CONFIG_HOME", val),
313                None => env::remove_var("XDG_CONFIG_HOME"),
314            }
315        }
316    }
317}