Skip to main content

lean_ctx/core/
opencode_config.rs

1//! Single source of truth for locating OpenCode's global config file.
2//!
3//! OpenCode accepts both `opencode.json` and `opencode.jsonc`. lean-ctx used to
4//! hardcode `opencode.json` in every writer, so a user whose config was
5//! `opencode.jsonc` got a *second*, competing global config containing nothing
6//! but the lean-ctx MCP entry and shadow-mode denies — their providers, models
7//! and foreign MCP servers appeared to vanish (#1585).
8//!
9//! Resolution rule, applied by `init`, `setup`, doctor, the shadow-mode
10//! permission updater and uninstall alike:
11//!
12//! 1. exactly one of the two exists → that file
13//! 2. both exist → `opencode.json` (OpenCode's own default name), reported via
14//!    [`Resolved::ambiguous`] so callers can warn instead of silently picking
15//! 3. neither exists → `opencode.json`, to be created
16
17use std::path::{Path, PathBuf};
18
19/// Where the OpenCode config lives, plus whether the choice was ambiguous.
20pub struct Resolved {
21    /// The file to read and write.
22    pub path: PathBuf,
23    /// Both `opencode.json` and `opencode.jsonc` exist on disk.
24    pub ambiguous: bool,
25}
26
27impl Resolved {
28    /// Display form for CLI output, e.g. `~/.config/opencode/opencode.jsonc`.
29    pub fn display(&self, home: &Path) -> String {
30        self.path.strip_prefix(home).map_or_else(
31            |_| self.path.display().to_string(),
32            |rest| format!("~/{}", rest.display()),
33        )
34    }
35}
36
37/// Resolve the config inside an OpenCode config directory.
38pub fn resolve_in_dir(dir: &Path) -> Resolved {
39    let json = dir.join("opencode.json");
40    let jsonc = dir.join("opencode.jsonc");
41    match (json.exists(), jsonc.exists()) {
42        (false, true) => Resolved {
43            path: jsonc,
44            ambiguous: false,
45        },
46        (true, true) => Resolved {
47            path: json,
48            ambiguous: true,
49        },
50        _ => Resolved {
51            path: json,
52            ambiguous: false,
53        },
54    }
55}
56
57/// Resolve OpenCode's global config directory.
58///
59/// `~/.config/opencode` on every platform — OpenCode uses the XDG layout on
60/// Windows too, and so does the rest of lean-ctx: the rules file, skills,
61/// detection and uninstall all address `home/.config/opencode`. Special-casing
62/// `%APPDATA%` here would point the MCP writer and doctor at a directory the
63/// uninstaller never visits and the rules writer never fills — and would make
64/// every caller that passes a test home write to the real user profile.
65pub fn config_dir(home: &Path) -> PathBuf {
66    home.join(".config/opencode")
67}
68
69/// Resolve the global OpenCode config for a given home directory.
70pub fn resolve(home: &Path) -> Resolved {
71    resolve_in_dir(&config_dir(home))
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn touch(p: &Path) {
79        std::fs::write(p, "{}").unwrap();
80    }
81
82    #[test]
83    fn picks_the_only_file_that_exists() {
84        let dir = tempfile::tempdir().unwrap();
85        touch(&dir.path().join("opencode.jsonc"));
86        let r = resolve_in_dir(dir.path());
87        assert_eq!(r.path.file_name().unwrap(), "opencode.jsonc");
88        assert!(!r.ambiguous);
89    }
90
91    #[test]
92    fn plain_json_is_used_when_it_is_the_one_present() {
93        let dir = tempfile::tempdir().unwrap();
94        touch(&dir.path().join("opencode.json"));
95        let r = resolve_in_dir(dir.path());
96        assert_eq!(r.path.file_name().unwrap(), "opencode.json");
97        assert!(!r.ambiguous);
98    }
99
100    #[test]
101    fn both_present_prefers_json_and_reports_ambiguity() {
102        let dir = tempfile::tempdir().unwrap();
103        touch(&dir.path().join("opencode.json"));
104        touch(&dir.path().join("opencode.jsonc"));
105        let r = resolve_in_dir(dir.path());
106        assert_eq!(r.path.file_name().unwrap(), "opencode.json");
107        assert!(
108            r.ambiguous,
109            "callers must be able to warn instead of silently choosing"
110        );
111    }
112
113    #[test]
114    fn neither_present_targets_json_for_creation() {
115        let dir = tempfile::tempdir().unwrap();
116        let r = resolve_in_dir(dir.path());
117        assert_eq!(r.path.file_name().unwrap(), "opencode.json");
118        assert!(!r.ambiguous);
119    }
120
121    #[test]
122    fn config_dir_stays_under_the_given_home_on_every_platform() {
123        // Windows CI caught this the hard way: a `%APPDATA%` branch here made
124        // the config resolver ignore its `home` argument, so five tests shared
125        // the runner's real profile and contaminated each other — and in
126        // production it would have split the config away from the rules file,
127        // detection and uninstall, which all use `home/.config/opencode`.
128        let home = Path::new("/tmp/leanctx-home-probe");
129        assert_eq!(config_dir(home), home.join(".config/opencode"));
130        assert!(config_dir(home).starts_with(home));
131    }
132
133    #[test]
134    fn jsonc_is_never_shadowed_by_a_file_we_would_create() {
135        // The #1585 regression: a user with only opencode.jsonc must not end up
136        // with lean-ctx writing a competing opencode.json.
137        let dir = tempfile::tempdir().unwrap();
138        touch(&dir.path().join("opencode.jsonc"));
139        assert_eq!(
140            resolve_in_dir(dir.path()).path,
141            dir.path().join("opencode.jsonc")
142        );
143    }
144}