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