Skip to main content

opencode_provider_manager/config_core/
paths.rs

1//! Platform-aware config path resolution for OpenCode.
2//!
3//! Follows OpenCode's documented path precedence:
4//! 1. Remote config (.well-known/opencode) — not managed by this tool
5//! 2. Global config (~/.config/opencode/opencode.json)
6//! 3. Custom config (OPENCODE_CONFIG env var)
7//! 4. Project config (./opencode.json, traversing up to git root)
8//! 5. .opencode directories — not managed by this tool
9//! 6. Inline config (OPENCODE_CONFIG_CONTENT env var) — not managed by this tool
10//! 7. Managed config files — read-only awareness
11//!
12//! Additional paths:
13//! - $HOME/.opencode.json (fallback)
14//! - $XDG_CONFIG_HOME/opencode/opencode.json (XDG fallback)
15//! - Auth: ~/.local/share/opencode/auth.json
16
17use super::error::{ConfigError, Result};
18use std::path::PathBuf;
19
20/// Which configuration layer to operate on.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum ConfigLayer {
23    /// Global config: `~/.config/opencode/opencode.json`
24    Global,
25    /// Project config: `./opencode.json`
26    Project,
27    /// Custom config specified by OPENCODE_CONFIG env var.
28    Custom,
29}
30
31/// Resolved paths for all config layers.
32#[derive(Debug, Clone)]
33pub struct ConfigPaths {
34    /// Global config file path.
35    pub global: PathBuf,
36    /// Project config file path (nearest opencode.json up to git root).
37    pub project: Option<PathBuf>,
38    /// Custom config path from OPENCODE_CONFIG env var.
39    pub custom: Option<PathBuf>,
40    /// Auth file path.
41    pub auth: PathBuf,
42    /// Cache directory for this tool.
43    pub cache_dir: PathBuf,
44}
45
46impl ConfigPaths {
47    /// Discover all config paths following OpenCode conventions.
48    ///
49    /// This respects environment variables:
50    /// - `OPENCODE_CONFIG`: custom config file path
51    /// - `OPENCODE_CONFIG_DIR`: custom config directory
52    /// - `OPENCODE_CONFIG_CONTENT`: inline config (not file-based, skipped)
53    /// - `XDG_CONFIG_HOME`: XDG config directory override
54    pub fn discover() -> Result<Self> {
55        let global = Self::global_config_path()?;
56        let project = Self::project_config_path()?;
57        let custom = std::env::var("OPENCODE_CONFIG").ok().map(PathBuf::from);
58        let auth = Self::auth_path()?;
59        let cache_dir = Self::cache_dir()?;
60
61        Ok(Self {
62            global,
63            project,
64            custom,
65            auth,
66            cache_dir,
67        })
68    }
69
70    /// Get the global config path.
71    ///
72    /// Checks in order:
73    /// 1. `OPENCODE_CONFIG_DIR` env var
74    /// 2. `~/.config/opencode/opencode.json`
75    /// 3. `$XDG_CONFIG_HOME/opencode/opencode.json`
76    /// 4. `$HOME/.opencode.json` (fallback)
77    pub fn global_config_path() -> Result<PathBuf> {
78        // OPENCODE_CONFIG_DIR takes priority
79        if let Ok(config_dir) = std::env::var("OPENCODE_CONFIG_DIR") {
80            let path = PathBuf::from(config_dir).join("opencode.json");
81            return Ok(path);
82        }
83
84        // Standard XDG path
85        if let Some(config_home) = dirs::config_dir() {
86            let path = config_home.join("opencode").join("opencode.json");
87            if path.exists() {
88                return Ok(path);
89            }
90        }
91
92        // XDG_CONFIG_HOME override
93        if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
94            let path = PathBuf::from(xdg_config)
95                .join("opencode")
96                .join("opencode.json");
97            if path.exists() {
98                return Ok(path);
99            }
100        }
101
102        // Fallback: $HOME/.opencode.json
103        if let Some(home) = dirs::home_dir() {
104            let fallback = home.join(".opencode.json");
105            if fallback.exists() {
106                return Ok(fallback);
107            }
108        }
109
110        // Default: create at standard location
111        dirs::config_dir()
112            .map(|d| d.join("opencode").join("opencode.json"))
113            .ok_or_else(|| {
114                ConfigError::Io(std::io::Error::new(
115                    std::io::ErrorKind::NotFound,
116                    "Cannot determine config directory",
117                ))
118            })
119    }
120
121    /// Find the project-level config by traversing up to the git root.
122    ///
123    /// Starts from the current directory and walks up the tree
124    /// until finding `opencode.json` or hitting the git root.
125    pub fn project_config_path() -> Result<Option<PathBuf>> {
126        let current_dir = std::env::current_dir().map_err(ConfigError::Io)?;
127
128        let mut dir = current_dir.as_path();
129        loop {
130            let config_path = dir.join("opencode.json");
131            if config_path.exists() {
132                return Ok(Some(config_path));
133            }
134
135            // Check if we've hit the git root (stop here even if no config)
136            let git_dir = dir.join(".git");
137            if git_dir.exists() {
138                // Also check git root for opencode.json before stopping
139                let root_config = dir.join("opencode.json");
140                if root_config.exists() {
141                    return Ok(Some(root_config));
142                }
143                return Ok(None);
144            }
145
146            // Walk up
147            match dir.parent() {
148                Some(parent) => dir = parent,
149                None => break,
150            }
151        }
152
153        Ok(None)
154    }
155
156    /// Get the auth.json file path.
157    pub fn auth_path() -> Result<PathBuf> {
158        // Follow OpenCode convention: ~/.local/share/opencode/auth.json
159        dirs::data_local_dir()
160            .or_else(dirs::data_dir)
161            .map(|d| d.join("opencode").join("auth.json"))
162            .ok_or_else(|| {
163                ConfigError::Io(std::io::Error::new(
164                    std::io::ErrorKind::NotFound,
165                    "Cannot determine data directory for auth.json",
166                ))
167            })
168    }
169
170    /// Get the cache directory for this tool.
171    pub fn cache_dir() -> Result<PathBuf> {
172        let cache = dirs::cache_dir()
173            .ok_or_else(|| {
174                ConfigError::Io(std::io::Error::new(
175                    std::io::ErrorKind::NotFound,
176                    "Cannot determine cache directory",
177                ))
178            })
179            .map(|p| p.join("opencode-provider-manager"))?;
180        Ok(cache)
181    }
182
183    /// Get the managed config path for the current platform (read-only awareness).
184    pub fn managed_config_path() -> Option<PathBuf> {
185        #[cfg(target_os = "macos")]
186        {
187            Some(PathBuf::from(
188                "/Library/Application Support/opencode/opencode.json",
189            ))
190        }
191
192        #[cfg(target_os = "linux")]
193        {
194            Some(PathBuf::from("/etc/opencode/opencode.json"))
195        }
196
197        #[cfg(target_os = "windows")]
198        {
199            std::env::var("ProgramData")
200                .ok()
201                .map(|p| PathBuf::from(p).join("opencode").join("opencode.json"))
202        }
203
204        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
205        {
206            None
207        }
208    }
209
210    /// Get the path for a specific config layer.
211    pub fn path_for_layer(&self, layer: ConfigLayer) -> Option<&PathBuf> {
212        match layer {
213            ConfigLayer::Global => Some(&self.global),
214            ConfigLayer::Project => self.project.as_ref(),
215            ConfigLayer::Custom => self.custom.as_ref(),
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_global_config_path_returns_something() {
226        // Should return a valid path even if file doesn't exist
227        let path = ConfigPaths::global_config_path().unwrap();
228        assert!(path.to_string_lossy().contains("opencode"));
229    }
230
231    #[test]
232    fn test_auth_path_returns_something() {
233        let path = ConfigPaths::auth_path().unwrap();
234        assert!(path.to_string_lossy().contains("opencode"));
235        assert!(path.to_string_lossy().contains("auth.json"));
236    }
237
238    #[test]
239    fn test_cache_dir_returns_something() {
240        let path = ConfigPaths::cache_dir().unwrap();
241        assert!(path.to_string_lossy().contains("opencode-provider-manager"));
242    }
243
244    #[test]
245    fn test_config_layer_enum_values() {
246        assert_eq!(ConfigLayer::Global, ConfigLayer::Global);
247        assert_eq!(ConfigLayer::Project, ConfigLayer::Project);
248        assert_eq!(ConfigLayer::Custom, ConfigLayer::Custom);
249    }
250
251    #[test]
252    fn test_discover_returns_structure() {
253        let paths = ConfigPaths::discover().unwrap();
254        assert!(!paths.global.to_string_lossy().is_empty());
255        assert!(!paths.auth.to_string_lossy().is_empty());
256        assert!(!paths.cache_dir.to_string_lossy().is_empty());
257    }
258
259    #[test]
260    fn test_path_for_layer() {
261        let paths = ConfigPaths::discover().unwrap();
262        assert!(paths.path_for_layer(ConfigLayer::Global).is_some());
263        // Project config may or may not exist
264        assert!(
265            paths.path_for_layer(ConfigLayer::Custom).is_none()
266                || paths.path_for_layer(ConfigLayer::Custom).is_some()
267        );
268    }
269}