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}