1use std::collections::{BTreeMap, HashMap};
5use std::path::{Path, PathBuf};
6
7use crate::host::Host;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum PermissionPolicy {
11 Inherit,
12 Allow,
13 Ask,
14 Deny,
15}
16
17impl PermissionPolicy {
18 pub fn as_str(self) -> &'static str {
19 match self {
20 PermissionPolicy::Inherit => "inherit",
21 PermissionPolicy::Allow => "allow",
22 PermissionPolicy::Ask => "ask",
23 PermissionPolicy::Deny => "deny",
24 }
25 }
26
27 pub fn parse(raw: &str) -> Option<Self> {
28 match raw.trim().to_ascii_lowercase().as_str() {
29 "inherit" => Some(PermissionPolicy::Inherit),
30 "allow" => Some(PermissionPolicy::Allow),
31 "ask" => Some(PermissionPolicy::Ask),
32 "deny" => Some(PermissionPolicy::Deny),
33 _ => None,
34 }
35 }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Source {
40 Env,
41 UserFile,
42 WorkspaceFile,
43 Default,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct Settings {
48 pub wait_timeout_s: u64,
49 pub poll_s: u64,
50 pub stall_s: u64,
51 pub max_turn_s: u64,
52 pub call_timeout_s: u64,
53 pub output_max_bytes: usize,
54 pub body_max_bytes: usize,
55 pub wait_max_s: u64,
56 pub opencode_bin: Option<PathBuf>,
57 pub server_url: Option<String>,
58 pub default_model: Option<String>,
59 pub default_agent: Option<String>,
60 pub permission_policy: PermissionPolicy,
61 pub permissions_locked: bool,
62 pub lock_source: Option<Source>,
63 pub sources: BTreeMap<&'static str, Source>,
64}
65
66impl Default for Settings {
67 fn default() -> Self {
68 Settings {
69 wait_timeout_s: 120,
70 poll_s: 10,
71 stall_s: 90,
72 max_turn_s: 1800,
73 call_timeout_s: 30,
74 output_max_bytes: 16384,
75 body_max_bytes: if cfg!(windows) { 24000 } else { 100000 },
76 wait_max_s: 300,
77 opencode_bin: None,
78 server_url: None,
79 default_model: None,
80 default_agent: None,
81 permission_policy: PermissionPolicy::Inherit,
82 permissions_locked: false,
83 lock_source: None,
84 sources: KEYS.iter().map(|k| (*k, Source::Default)).collect(),
85 }
86 }
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub enum NoteReason {
91 UnknownKey,
92 InvalidValue,
93 Clamped { min: i64, max: i64 },
94 ProtectedInWorkspace,
95 OverriddenByStickyLock,
96 UserinfoInUrl,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Note {
101 pub source: Source,
102 pub key: String,
103 pub value: String,
104 pub reason: NoteReason,
105}
106
107pub struct Input<'a> {
108 pub host: Host,
109 pub env: &'a dyn Fn(&str) -> Option<String>,
110 pub user_file: Option<&'a str>,
111 pub workspace_file: Option<&'a str>,
112}
113
114const KEYS: [&str; 14] = [
116 "wait_max_s",
117 "wait_timeout_s",
118 "poll_s",
119 "stall_s",
120 "max_turn_s",
121 "call_timeout_s",
122 "output_max_bytes",
123 "body_max_bytes",
124 "opencode_bin",
125 "server_url",
126 "default_model",
127 "default_agent",
128 "permission_policy",
129 "permissions_locked",
130];
131
132fn is_protected(key: &str) -> bool {
133 matches!(
134 key,
135 "opencode_bin"
136 | "server_url"
137 | "default_model"
138 | "default_agent"
139 | "permission_policy"
140 | "permissions_locked"
141 | "wait_max_s"
142 )
143}
144
145pub fn env_name(key: &str) -> String {
146 match key {
147 "opencode_bin" => "OPENCODE_CTL_BIN".to_string(),
148 "server_url" => "OPENCODE_CTL_SERVER".to_string(),
149 other => format!("OPENCODE_CTL_{}", other.to_ascii_uppercase()),
150 }
151}
152
153fn blank(v: &str) -> bool {
154 v.trim().is_empty()
155}
156
157pub fn parse_frontmatter(source: &str) -> Vec<(String, String)> {
159 let mut lines = source.trim_start_matches('\u{feff}').lines();
160 let mut out = Vec::new();
161 match lines.next() {
162 Some(first) if first.trim_end() == "---" => {}
163 _ => return out,
164 }
165 for line in lines {
166 let line = line.trim();
167 if line == "---" {
168 break;
169 }
170 if line.is_empty() || line.starts_with('#') {
171 continue;
172 }
173 let Some((key, value)) = line.split_once(':') else {
174 continue;
175 };
176 let value = value.trim();
177 let value = ['"', '\'']
178 .iter()
179 .find_map(|q| value.strip_prefix(*q).and_then(|v| v.strip_suffix(*q)))
180 .unwrap_or(value);
181 out.push((key.trim().to_string(), value.to_string()));
182 }
183 out
184}
185
186pub fn ctl_home(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
187 let nonblank = |k: &str| env(k).filter(|v| !blank(v));
188 if let Some(h) = nonblank("OPENCODE_CTL_HOME") {
189 return PathBuf::from(h);
190 }
191 match nonblank("HOME").or_else(|| nonblank("USERPROFILE")) {
192 Some(h) => PathBuf::from(h).join(".opencode-ctl"),
193 None => PathBuf::from(".opencode-ctl"),
194 }
195}
196
197pub fn load_from_fs(
198 host: Host,
199 home: &Path,
200 cwd: &Path,
201 env: &dyn Fn(&str) -> Option<String>,
202) -> (Settings, Vec<Note>) {
203 let user = std::fs::read_to_string(home.join("settings.local.md")).ok();
204 let ws = host
205 .workspace_settings_rel()
206 .and_then(|rel| std::fs::read_to_string(cwd.join(rel)).ok());
207 load(Input {
208 host,
209 env,
210 user_file: user.as_deref(),
211 workspace_file: ws.as_deref(),
212 })
213}
214
215fn userinfo_in_url(url: &str) -> bool {
216 let rest = url.split_once("://").map_or(url, |(_, r)| r);
217 let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
218 authority.contains('@')
219}
220
221fn redact_userinfo(url: &str) -> String {
222 let (scheme, rest) = match url.split_once("://") {
223 Some((s, r)) => (format!("{s}://"), r),
224 None => (String::new(), url),
225 };
226 let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
227 let (authority, tail) = rest.split_at(end);
228 let host = authority.rsplit_once('@').map_or(authority, |(_, h)| h);
229 format!("{scheme}***@{host}{tail}")
230}
231
232fn clamp_bounds(key: &str, source: Source, host: Host) -> (u64, u64, bool) {
233 match key {
234 "poll_s" => (2, 60, false),
235 "stall_s" => (30, 600, false),
236 "max_turn_s" => (60, 7200, false),
237 "call_timeout_s" => (5, 120, false),
238 "body_max_bytes" => (1024, 100000, false),
239 "output_max_bytes" => (
240 1024,
241 if source == Source::WorkspaceFile {
242 65536
243 } else {
244 1048576
245 },
246 false,
247 ),
248 "wait_max_s" => (30, if host == Host::Opencode { 300 } else { 600 }, false),
249 _ => (0, u64::MAX, true), }
251}
252
253pub fn load(input: Input) -> (Settings, Vec<Note>) {
254 let mut s = Settings::default();
255 let mut notes = Vec::new();
256
257 let user: HashMap<String, String> = input
258 .user_file
259 .map(|f| parse_frontmatter(f).into_iter().collect())
260 .unwrap_or_default();
261 let ws: HashMap<String, String> = input
262 .workspace_file
263 .map(|f| parse_frontmatter(f).into_iter().collect())
264 .unwrap_or_default();
265
266 for (src, map) in [(Source::UserFile, &user), (Source::WorkspaceFile, &ws)] {
267 let mut keys: Vec<&String> = map.keys().filter(|k| !KEYS.contains(&k.as_str())).collect();
268 keys.sort();
269 for k in keys {
270 notes.push(Note {
271 source: src,
272 key: k.clone(),
273 value: map[k].clone(),
274 reason: NoteReason::UnknownKey,
275 });
276 }
277 }
278
279 let cands = |key: &str| -> Vec<(Source, String)> {
283 let mut v = Vec::new();
284 if let Some(e) = (input.env)(&env_name(key)) {
285 if !blank(&e) || key == "server_url" {
286 v.push((Source::Env, e));
287 }
288 }
289 for (src, map) in [(Source::UserFile, &user), (Source::WorkspaceFile, &ws)] {
290 if let Some(x) = map.get(key).filter(|x| !blank(x)) {
291 v.push((src, x.clone()));
292 }
293 }
294 v
295 };
296
297 for key in KEYS {
298 if key == "permission_policy" || key == "permissions_locked" {
299 continue;
300 }
301 let mut resolved = false;
302 for (src, raw) in cands(key) {
303 if src == Source::WorkspaceFile && is_protected(key) {
304 notes.push(Note {
305 source: src,
306 key: key.to_string(),
307 value: raw,
308 reason: NoteReason::ProtectedInWorkspace,
309 });
310 continue;
311 }
312 if resolved {
313 continue;
314 }
315 let mut note = |reason: NoteReason, value: String| {
316 notes.push(Note {
317 source: src,
318 key: key.to_string(),
319 value,
320 reason,
321 })
322 };
323 let trimmed = raw.trim();
324 match key {
325 "opencode_bin" => s.opencode_bin = Some(PathBuf::from(trimmed)),
326 "default_model" => s.default_model = Some(trimmed.to_string()),
327 "default_agent" => s.default_agent = Some(trimmed.to_string()),
328 "server_url" => {
329 if trimmed.is_empty() {
330 s.server_url = None;
331 } else if userinfo_in_url(trimmed) {
332 note(NoteReason::UserinfoInUrl, redact_userinfo(trimmed));
333 continue;
334 } else {
335 s.server_url = Some(trimmed.to_string());
336 }
337 }
338 _ => {
339 let Ok(n) = trimmed.parse::<u64>() else {
340 note(NoteReason::InvalidValue, raw.clone());
341 continue;
342 };
343 let (min, mut max, ceiling_is_wait_max) = clamp_bounds(key, src, input.host);
344 let min = if ceiling_is_wait_max { 5 } else { min };
345 if ceiling_is_wait_max {
346 max = s.wait_max_s;
347 }
348 let v = n.clamp(min, max);
349 if v != n {
350 note(
351 NoteReason::Clamped {
352 min: min as i64,
353 max: max as i64,
354 },
355 raw.clone(),
356 );
357 }
358 match key {
359 "wait_max_s" => s.wait_max_s = v,
360 "wait_timeout_s" => s.wait_timeout_s = v,
361 "poll_s" => s.poll_s = v,
362 "stall_s" => s.stall_s = v,
363 "max_turn_s" => s.max_turn_s = v,
364 "call_timeout_s" => s.call_timeout_s = v,
365 "output_max_bytes" => s.output_max_bytes = v as usize,
366 _ => s.body_max_bytes = v as usize,
367 }
368 }
369 }
370 s.sources.insert(key, src);
371 resolved = true;
372 }
373 }
374
375 if s.sources["wait_timeout_s"] == Source::Default && s.wait_timeout_s > s.wait_max_s {
376 s.wait_timeout_s = s.wait_max_s;
377 }
378
379 resolve_permissions(&mut s, &mut notes, &cands);
380 (s, notes)
381}
382
383fn resolve_permissions(
384 s: &mut Settings,
385 notes: &mut Vec<Note>,
386 cands: &dyn Fn(&str) -> Vec<(Source, String)>,
387) {
388 let push = |notes: &mut Vec<Note>, src, key: &str, value: String, reason| {
389 notes.push(Note {
390 source: src,
391 key: key.to_string(),
392 value,
393 reason,
394 })
395 };
396 let protected_only = |notes: &mut Vec<Note>, key: &str| -> Vec<(Source, String)> {
397 let mut kept = Vec::new();
398 for (src, raw) in cands(key) {
399 if src == Source::WorkspaceFile {
400 push(notes, src, key, raw, NoteReason::ProtectedInWorkspace);
401 } else {
402 kept.push((src, raw));
403 }
404 }
405 kept
406 };
407
408 let mut lock_vals: Vec<(Source, String, bool)> = Vec::new();
410 for (src, raw) in protected_only(notes, "permissions_locked") {
411 match raw.trim().to_ascii_lowercase().as_str() {
412 "true" => lock_vals.push((src, raw, true)),
413 "false" => lock_vals.push((src, raw, false)),
414 _ => push(
415 notes,
416 src,
417 "permissions_locked",
418 raw,
419 NoteReason::InvalidValue,
420 ),
421 }
422 }
423 if let Some((src, _, _)) = lock_vals.iter().find(|v| v.2) {
424 s.permissions_locked = true;
425 s.lock_source = Some(*src);
426 s.sources.insert("permissions_locked", *src);
427 for (osrc, raw, _) in lock_vals.iter().filter(|v| !v.2) {
428 push(
429 notes,
430 *osrc,
431 "permissions_locked",
432 raw.clone(),
433 NoteReason::OverriddenByStickyLock,
434 );
435 }
436 } else if let Some((src, _, _)) = lock_vals.first() {
437 s.sources.insert("permissions_locked", *src);
438 }
439
440 let mut resolved = false;
442 for (src, raw) in protected_only(notes, "permission_policy") {
443 let Some(p) = PermissionPolicy::parse(&raw) else {
444 push(
445 notes,
446 src,
447 "permission_policy",
448 raw,
449 NoteReason::InvalidValue,
450 );
451 continue;
452 };
453 let wins = match s.lock_source {
454 Some(l) => src == l,
455 None => !resolved,
456 };
457 if wins && !resolved {
458 s.permission_policy = p;
459 s.sources.insert("permission_policy", src);
460 resolved = true;
461 } else if s.lock_source.is_some() {
462 push(
463 notes,
464 src,
465 "permission_policy",
466 raw,
467 NoteReason::OverriddenByStickyLock,
468 );
469 }
470 }
471}