1use std::collections::BTreeMap;
2use std::fmt::{Display, Formatter};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::json::JsonValue;
7use crate::sandbox::{FilesystemIsolationMode, SandboxConfig};
8
9pub const TERNLANG_CLI_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum ConfigSource {
13 User,
14 Project,
15 Local,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum ResolvedPermissionMode {
20 ReadOnly,
21 WorkspaceWrite,
22 DangerFullAccess,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ConfigEntry {
27 pub source: ConfigSource,
28 pub path: PathBuf,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct RuntimeConfig {
33 merged: BTreeMap<String, JsonValue>,
34 loaded_entries: Vec<ConfigEntry>,
35 feature_config: RuntimeFeatureConfig,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Default)]
39pub struct RuntimeFeatureConfig {
40 hooks: RuntimeHookConfig,
41 mcp: McpConfigCollection,
42 oauth: Option<OAuthConfig>,
43 model: Option<String>,
44 permission_mode: Option<ResolvedPermissionMode>,
45 sandbox: SandboxConfig,
46 providers: BTreeMap<String, ProviderConfig>,
47 default_provider: Option<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct ProviderConfig {
52 pub api_key: Option<String>,
53 pub model: Option<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Default)]
57pub struct RuntimeHookConfig {
58 pre_tool_use: Vec<String>,
59 post_tool_use: Vec<String>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Default)]
63pub struct McpConfigCollection {
64 servers: BTreeMap<String, ScopedMcpServerConfig>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct ScopedMcpServerConfig {
69 pub scope: ConfigSource,
70 pub config: McpServerConfig,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum McpTransport {
75 Stdio,
76 Sse,
77 Http,
78 Ws,
79 Sdk,
80 TernlangAiProxy,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum McpServerConfig {
85 Stdio(McpStdioServerConfig),
86 Sse(McpRemoteServerConfig),
87 Http(McpRemoteServerConfig),
88 Ws(McpWebSocketServerConfig),
89 Sdk(McpSdkServerConfig),
90 TernlangAiProxy(McpTernlangAiProxyServerConfig),
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct McpStdioServerConfig {
95 pub command: String,
96 pub args: Vec<String>,
97 pub env: BTreeMap<String, String>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct McpRemoteServerConfig {
102 pub url: String,
103 pub headers: BTreeMap<String, String>,
104 pub headers_helper: Option<String>,
105 pub oauth: Option<McpOAuthConfig>,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct McpWebSocketServerConfig {
110 pub url: String,
111 pub headers: BTreeMap<String, String>,
112 pub headers_helper: Option<String>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct McpSdkServerConfig {
117 pub name: String,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct McpTernlangAiProxyServerConfig {
122 pub url: String,
123 pub id: String,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct McpOAuthConfig {
128 pub client_id: Option<String>,
129 pub callback_port: Option<u16>,
130 pub auth_server_metadata_url: Option<String>,
131 pub xaa: Option<bool>,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct OAuthConfig {
136 pub client_id: String,
137 pub authorize_url: String,
138 pub token_url: String,
139 pub callback_port: Option<u16>,
140 pub manual_redirect_url: Option<String>,
141 pub scopes: Vec<String>,
142}
143
144#[derive(Debug)]
145pub enum ConfigError {
146 Io(std::io::Error),
147 Parse(String),
148}
149
150impl Display for ConfigError {
151 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
152 match self {
153 Self::Io(error) => write!(f, "{error}"),
154 Self::Parse(error) => write!(f, "{error}"),
155 }
156 }
157}
158
159impl std::error::Error for ConfigError {}
160
161impl From<std::io::Error> for ConfigError {
162 fn from(value: std::io::Error) -> Self {
163 Self::Io(value)
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct ConfigLoader {
169 cwd: PathBuf,
170 config_home: PathBuf,
171}
172
173impl ConfigLoader {
174 #[must_use]
175 pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
176 Self {
177 cwd: cwd.into(),
178 config_home: config_home.into(),
179 }
180 }
181
182 #[must_use]
183 pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
184 let cwd = cwd.into();
185 let config_home = std::env::var_os("TERNLANG_CONFIG_HOME")
186 .map(PathBuf::from)
187 .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".ternlang")))
188 .unwrap_or_else(|| PathBuf::from(".ternlang"));
189 Self { cwd, config_home }
190 }
191
192 #[must_use]
193 pub fn discover(&self) -> Vec<ConfigEntry> {
194 let user_legacy_path = self.config_home.parent().map_or_else(
195 || PathBuf::from(".ternlang.json"),
196 |parent| parent.join(".ternlang.json"),
197 );
198 vec![
199 ConfigEntry {
200 source: ConfigSource::User,
201 path: user_legacy_path,
202 },
203 ConfigEntry {
204 source: ConfigSource::User,
205 path: self.config_home.join("settings.json"),
206 },
207 ConfigEntry {
208 source: ConfigSource::Project,
209 path: self.cwd.join(".ternlang.json"),
210 },
211 ConfigEntry {
212 source: ConfigSource::Project,
213 path: self.cwd.join(".ternlang").join("settings.json"),
214 },
215 ConfigEntry {
216 source: ConfigSource::Local,
217 path: self.cwd.join(".ternlang").join("settings.local.json"),
218 },
219 ]
220 }
221
222 pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
223 let mut merged = BTreeMap::new();
224 let mut loaded_entries = Vec::new();
225 let mut mcp_servers = BTreeMap::new();
226
227 for entry in self.discover() {
228 let Some(value) = read_optional_json_object(&entry.path)? else {
229 continue;
230 };
231 merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
232 deep_merge_objects(&mut merged, &value);
233 loaded_entries.push(entry);
234 }
235
236 let merged_value = JsonValue::Object(merged.clone());
237
238 let feature_config = RuntimeFeatureConfig {
239 hooks: parse_optional_hooks_config(&merged_value)?,
240 mcp: McpConfigCollection {
241 servers: mcp_servers,
242 },
243 oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
244 model: parse_optional_model(&merged_value),
245 permission_mode: parse_optional_permission_mode(&merged_value)?,
246 sandbox: parse_optional_sandbox_config(&merged_value)?,
247 providers: parse_optional_providers(&merged_value)?,
248 default_provider: parse_optional_default_provider(&merged_value),
249 };
250
251 Ok(RuntimeConfig {
252 merged,
253 loaded_entries,
254 feature_config,
255 })
256 }}
257
258impl RuntimeConfig {
259 #[must_use]
260 pub fn empty() -> Self {
261 Self {
262 merged: BTreeMap::new(),
263 loaded_entries: Vec::new(),
264 feature_config: RuntimeFeatureConfig::default(),
265 }
266 }
267
268 #[must_use]
269 pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
270 &self.merged
271 }
272
273 #[must_use]
274 pub fn loaded_entries(&self) -> &[ConfigEntry] {
275 &self.loaded_entries
276 }
277
278 #[must_use]
279 pub fn get(&self, key: &str) -> Option<&JsonValue> {
280 self.merged.get(key)
281 }
282
283 #[must_use]
284 pub fn as_json(&self) -> JsonValue {
285 JsonValue::Object(self.merged.clone())
286 }
287
288 #[must_use]
289 pub fn feature_config(&self) -> &RuntimeFeatureConfig {
290 &self.feature_config
291 }
292
293 #[must_use]
294 pub fn mcp(&self) -> &McpConfigCollection {
295 &self.feature_config.mcp
296 }
297
298 #[must_use]
299 pub fn hooks(&self) -> &RuntimeHookConfig {
300 &self.feature_config.hooks
301 }
302
303 #[must_use]
304 pub fn oauth(&self) -> Option<&OAuthConfig> {
305 self.feature_config.oauth.as_ref()
306 }
307
308 #[must_use]
309 pub fn model(&self) -> Option<&str> {
310 self.feature_config.model.as_deref()
311 }
312
313 #[must_use]
314 pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
315 self.feature_config.permission_mode
316 }
317
318 #[must_use]
319 pub fn sandbox(&self) -> &SandboxConfig {
320 &self.feature_config.sandbox
321 }
322}
323
324impl RuntimeFeatureConfig {
325 #[must_use]
326 pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
327 self.hooks = hooks;
328 self
329 }
330
331 #[must_use]
332 pub fn hooks(&self) -> &RuntimeHookConfig {
333 &self.hooks
334 }
335
336 #[must_use]
337 pub fn mcp(&self) -> &McpConfigCollection {
338 &self.mcp
339 }
340
341 #[must_use]
342 pub fn oauth(&self) -> Option<&OAuthConfig> {
343 self.oauth.as_ref()
344 }
345
346 #[must_use]
347 pub fn model(&self) -> Option<&str> {
348 self.model.as_deref()
349 }
350
351 #[must_use]
352 pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
353 self.permission_mode
354 }
355
356 #[must_use]
357 pub fn sandbox(&self) -> &SandboxConfig {
358 &self.sandbox
359 }
360}
361
362impl RuntimeHookConfig {
363 #[must_use]
364 pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
365 Self {
366 pre_tool_use,
367 post_tool_use,
368 }
369 }
370
371 #[must_use]
372 pub fn pre_tool_use(&self) -> &[String] {
373 &self.pre_tool_use
374 }
375
376 #[must_use]
377 pub fn post_tool_use(&self) -> &[String] {
378 &self.post_tool_use
379 }
380}
381
382impl McpConfigCollection {
383 #[must_use]
384 pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
385 &self.servers
386 }
387
388 #[must_use]
389 pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
390 self.servers.get(name)
391 }
392}
393
394impl ScopedMcpServerConfig {
395 #[must_use]
396 pub fn transport(&self) -> McpTransport {
397 self.config.transport()
398 }
399}
400
401impl McpServerConfig {
402 #[must_use]
403 pub fn transport(&self) -> McpTransport {
404 match self {
405 Self::Stdio(_) => McpTransport::Stdio,
406 Self::Sse(_) => McpTransport::Sse,
407 Self::Http(_) => McpTransport::Http,
408 Self::Ws(_) => McpTransport::Ws,
409 Self::Sdk(_) => McpTransport::Sdk,
410 Self::TernlangAiProxy(_) => McpTransport::TernlangAiProxy,
411 }
412 }
413}
414
415fn read_optional_json_object(
416 path: &Path,
417) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
418 let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".ternlang.json");
419 let contents = match fs::read_to_string(path) {
420 Ok(contents) => contents,
421 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
422 Err(error) => return Err(ConfigError::Io(error)),
423 };
424
425 if contents.trim().is_empty() {
426 return Ok(Some(BTreeMap::new()));
427 }
428
429 let parsed = match JsonValue::parse(&contents) {
430 Ok(parsed) => parsed,
431 Err(_error) if is_legacy_config => return Ok(None),
432 Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
433 };
434 let Some(object) = parsed.as_object() else {
435 if is_legacy_config {
436 return Ok(None);
437 }
438 return Err(ConfigError::Parse(format!(
439 "{}: top-level settings value must be a JSON object",
440 path.display()
441 )));
442 };
443 Ok(Some(object.clone()))
444}
445
446fn merge_mcp_servers(
447 target: &mut BTreeMap<String, ScopedMcpServerConfig>,
448 source: ConfigSource,
449 root: &BTreeMap<String, JsonValue>,
450 path: &Path,
451) -> Result<(), ConfigError> {
452 let Some(mcp_servers) = root.get("mcpServers") else {
453 return Ok(());
454 };
455 let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
456 for (name, value) in servers {
457 let parsed = parse_mcp_server_config(
458 name,
459 value,
460 &format!("{}: mcpServers.{name}", path.display()),
461 )?;
462 target.insert(
463 name.clone(),
464 ScopedMcpServerConfig {
465 scope: source,
466 config: parsed,
467 },
468 );
469 }
470 Ok(())
471}
472
473fn parse_optional_model(root: &JsonValue) -> Option<String> {
474 root.as_object()
475 .and_then(|object| object.get("model"))
476 .and_then(JsonValue::as_str)
477 .map(ToOwned::to_owned)
478}
479
480fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
481 let Some(object) = root.as_object() else {
482 return Ok(RuntimeHookConfig::default());
483 };
484 let Some(hooks_value) = object.get("hooks") else {
485 return Ok(RuntimeHookConfig::default());
486 };
487 let hooks = expect_object(hooks_value, "merged settings.hooks")?;
488 Ok(RuntimeHookConfig {
489 pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
490 .unwrap_or_default(),
491 post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
492 .unwrap_or_default(),
493 })
494}
495
496fn parse_optional_permission_mode(
497 root: &JsonValue,
498) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
499 let Some(object) = root.as_object() else {
500 return Ok(None);
501 };
502 if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
503 return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
504 }
505 let Some(mode) = object
506 .get("permissions")
507 .and_then(JsonValue::as_object)
508 .and_then(|permissions| permissions.get("defaultMode"))
509 .and_then(JsonValue::as_str)
510 else {
511 return Ok(None);
512 };
513 parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
514}
515
516fn parse_permission_mode_label(
517 mode: &str,
518 context: &str,
519) -> Result<ResolvedPermissionMode, ConfigError> {
520 match mode {
521 "default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
522 "acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
523 "dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
524 other => Err(ConfigError::Parse(format!(
525 "{context}: unsupported permission mode {other}"
526 ))),
527 }
528}
529
530fn parse_optional_sandbox_config(root: &JsonValue) -> Result<SandboxConfig, ConfigError> {
531 let Some(object) = root.as_object() else {
532 return Ok(SandboxConfig::default());
533 };
534 let Some(sandbox_value) = object.get("sandbox") else {
535 return Ok(SandboxConfig::default());
536 };
537 let sandbox = expect_object(sandbox_value, "merged settings.sandbox")?;
538 let filesystem_mode = optional_string(sandbox, "filesystemMode", "merged settings.sandbox")?
539 .map(parse_filesystem_mode_label)
540 .transpose()?;
541 Ok(SandboxConfig {
542 enabled: optional_bool(sandbox, "enabled", "merged settings.sandbox")?,
543 namespace_restrictions: optional_bool(
544 sandbox,
545 "namespaceRestrictions",
546 "merged settings.sandbox",
547 )?,
548 network_isolation: optional_bool(sandbox, "networkIsolation", "merged settings.sandbox")?,
549 filesystem_mode,
550 allowed_mounts: optional_string_array(sandbox, "allowedMounts", "merged settings.sandbox")?
551 .unwrap_or_default(),
552 })
553}
554
555fn parse_filesystem_mode_label(value: &str) -> Result<FilesystemIsolationMode, ConfigError> {
556 match value {
557 "off" => Ok(FilesystemIsolationMode::Off),
558 "workspace-only" => Ok(FilesystemIsolationMode::WorkspaceOnly),
559 "allow-list" => Ok(FilesystemIsolationMode::AllowList),
560 other => Err(ConfigError::Parse(format!(
561 "merged settings.sandbox.filesystemMode: unsupported filesystem mode {other}"
562 ))),
563 }
564}
565
566fn parse_optional_oauth_config(
567 root: &JsonValue,
568 context: &str,
569) -> Result<Option<OAuthConfig>, ConfigError> {
570 let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
571 return Ok(None);
572 };
573 let object = expect_object(oauth_value, context)?;
574 let client_id = expect_string(object, "clientId", context)?.to_string();
575 let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
576 let token_url = expect_string(object, "tokenUrl", context)?.to_string();
577 let callback_port = optional_u16(object, "callbackPort", context)?;
578 let manual_redirect_url =
579 optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
580 let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
581 Ok(Some(OAuthConfig {
582 client_id,
583 authorize_url,
584 token_url,
585 callback_port,
586 manual_redirect_url,
587 scopes,
588 }))
589}
590
591fn parse_mcp_server_config(
592 server_name: &str,
593 value: &JsonValue,
594 context: &str,
595) -> Result<McpServerConfig, ConfigError> {
596 let object = expect_object(value, context)?;
597 let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
598 match server_type {
599 "stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
600 command: expect_string(object, "command", context)?.to_string(),
601 args: optional_string_array(object, "args", context)?.unwrap_or_default(),
602 env: optional_string_map(object, "env", context)?.unwrap_or_default(),
603 })),
604 "sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
605 object, context,
606 )?)),
607 "http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
608 object, context,
609 )?)),
610 "ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
611 url: expect_string(object, "url", context)?.to_string(),
612 headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
613 headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
614 })),
615 "sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
616 name: expect_string(object, "name", context)?.to_string(),
617 })),
618 "ternlangai-proxy" => Ok(McpServerConfig::TernlangAiProxy(
619 McpTernlangAiProxyServerConfig {
620 url: expect_string(object, "url", context)?.to_string(),
621 id: expect_string(object, "id", context)?.to_string(),
622 },
623 )),
624 other => Err(ConfigError::Parse(format!(
625 "{context}: unsupported MCP server type for {server_name}: {other}"
626 ))),
627 }
628}
629
630fn parse_mcp_remote_server_config(
631 object: &BTreeMap<String, JsonValue>,
632 context: &str,
633) -> Result<McpRemoteServerConfig, ConfigError> {
634 Ok(McpRemoteServerConfig {
635 url: expect_string(object, "url", context)?.to_string(),
636 headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
637 headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
638 oauth: parse_optional_mcp_oauth_config(object, context)?,
639 })
640}
641
642fn parse_optional_mcp_oauth_config(
643 object: &BTreeMap<String, JsonValue>,
644 context: &str,
645) -> Result<Option<McpOAuthConfig>, ConfigError> {
646 let Some(value) = object.get("oauth") else {
647 return Ok(None);
648 };
649 let oauth = expect_object(value, &format!("{context}.oauth"))?;
650 Ok(Some(McpOAuthConfig {
651 client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
652 callback_port: optional_u16(oauth, "callbackPort", context)?,
653 auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
654 .map(str::to_string),
655 xaa: optional_bool(oauth, "xaa", context)?,
656 }))
657}
658
659fn expect_object<'a>(
660 value: &'a JsonValue,
661 context: &str,
662) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
663 value
664 .as_object()
665 .ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
666}
667
668fn expect_string<'a>(
669 object: &'a BTreeMap<String, JsonValue>,
670 key: &str,
671 context: &str,
672) -> Result<&'a str, ConfigError> {
673 object
674 .get(key)
675 .and_then(JsonValue::as_str)
676 .ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
677}
678
679fn optional_string<'a>(
680 object: &'a BTreeMap<String, JsonValue>,
681 key: &str,
682 context: &str,
683) -> Result<Option<&'a str>, ConfigError> {
684 match object.get(key) {
685 Some(value) => value
686 .as_str()
687 .map(Some)
688 .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
689 None => Ok(None),
690 }
691}
692
693fn optional_bool(
694 object: &BTreeMap<String, JsonValue>,
695 key: &str,
696 context: &str,
697) -> Result<Option<bool>, ConfigError> {
698 match object.get(key) {
699 Some(value) => value
700 .as_bool()
701 .map(Some)
702 .ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
703 None => Ok(None),
704 }
705}
706
707fn optional_u16(
708 object: &BTreeMap<String, JsonValue>,
709 key: &str,
710 context: &str,
711) -> Result<Option<u16>, ConfigError> {
712 match object.get(key) {
713 Some(value) => {
714 let Some(number) = value.as_i64() else {
715 return Err(ConfigError::Parse(format!(
716 "{context}: field {key} must be an integer"
717 )));
718 };
719 let number = u16::try_from(number).map_err(|_| {
720 ConfigError::Parse(format!("{context}: field {key} is out of range"))
721 })?;
722 Ok(Some(number))
723 }
724 None => Ok(None),
725 }
726}
727
728fn optional_string_array(
729 object: &BTreeMap<String, JsonValue>,
730 key: &str,
731 context: &str,
732) -> Result<Option<Vec<String>>, ConfigError> {
733 match object.get(key) {
734 Some(value) => {
735 let Some(array) = value.as_array() else {
736 return Err(ConfigError::Parse(format!(
737 "{context}: field {key} must be an array"
738 )));
739 };
740 array
741 .iter()
742 .map(|item| {
743 item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
744 ConfigError::Parse(format!(
745 "{context}: field {key} must contain only strings"
746 ))
747 })
748 })
749 .collect::<Result<Vec<_>, _>>()
750 .map(Some)
751 }
752 None => Ok(None),
753 }
754}
755
756fn optional_string_map(
757 object: &BTreeMap<String, JsonValue>,
758 key: &str,
759 context: &str,
760) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
761 match object.get(key) {
762 Some(value) => {
763 let Some(map) = value.as_object() else {
764 return Err(ConfigError::Parse(format!(
765 "{context}: field {key} must be an object"
766 )));
767 };
768 map.iter()
769 .map(|(entry_key, entry_value)| {
770 entry_value
771 .as_str()
772 .map(|text| (entry_key.clone(), text.to_string()))
773 .ok_or_else(|| {
774 ConfigError::Parse(format!(
775 "{context}: field {key} must contain only string values"
776 ))
777 })
778 })
779 .collect::<Result<BTreeMap<_, _>, _>>()
780 .map(Some)
781 }
782 None => Ok(None),
783 }
784}
785
786fn deep_merge_objects(
787 target: &mut BTreeMap<String, JsonValue>,
788 source: &BTreeMap<String, JsonValue>,
789) {
790 for (key, value) in source {
791 match (target.get_mut(key), value) {
792 (Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
793 deep_merge_objects(existing, incoming);
794 }
795 _ => {
796 target.insert(key.clone(), value.clone());
797 }
798 }
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::{
805 ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
806 TERNLANG_CLI_SETTINGS_SCHEMA_NAME,
807 };
808 use crate::json::JsonValue;
809 use crate::sandbox::FilesystemIsolationMode;
810 use std::fs;
811 use std::time::{SystemTime, UNIX_EPOCH};
812
813 fn temp_dir() -> std::path::PathBuf {
814 let nanos = SystemTime::now()
815 .duration_since(UNIX_EPOCH)
816 .expect("time should be after epoch")
817 .as_nanos();
818 std::env::temp_dir().join(format!("runtime-config-{nanos}"))
819 }
820
821 #[test]
822 fn rejects_non_object_settings_files() {
823 let root = temp_dir();
824 let cwd = root.join("project");
825 let home = root.join("home").join(".ternlang");
826 fs::create_dir_all(&home).expect("home config dir");
827 fs::create_dir_all(&cwd).expect("project dir");
828 fs::write(home.join("settings.json"), "[]").expect("write bad settings");
829
830 let error = ConfigLoader::new(&cwd, &home)
831 .load()
832 .expect_err("config should fail");
833 assert!(error
834 .to_string()
835 .contains("top-level settings value must be a JSON object"));
836
837 fs::remove_dir_all(root).expect("cleanup temp dir");
838 }
839
840 #[test]
841 fn loads_and_merges_ternlang_cli_config_files_by_precedence() {
842 let root = temp_dir();
843 let cwd = root.join("project");
844 let home = root.join("home").join(".ternlang");
845 fs::create_dir_all(cwd.join(".ternlang")).expect("project config dir");
846 fs::create_dir_all(&home).expect("home config dir");
847
848 fs::write(
849 home.parent().expect("home parent").join(".ternlang.json"),
850 r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
851 )
852 .expect("write user compat config");
853 fs::write(
854 home.join("settings.json"),
855 r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
856 )
857 .expect("write user settings");
858 fs::write(
859 cwd.join(".ternlang.json"),
860 r#"{"model":"project-compat","env":{"B":"2"}}"#,
861 )
862 .expect("write project compat config");
863 fs::write(
864 cwd.join(".ternlang").join("settings.json"),
865 r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
866 )
867 .expect("write project settings");
868 fs::write(
869 cwd.join(".ternlang").join("settings.local.json"),
870 r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
871 )
872 .expect("write local settings");
873
874 let loaded = ConfigLoader::new(&cwd, &home)
875 .load()
876 .expect("config should load");
877
878 assert_eq!(TERNLANG_CLI_SETTINGS_SCHEMA_NAME, "SettingsSchema");
879 assert_eq!(loaded.loaded_entries().len(), 5);
880 assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
881 assert_eq!(
882 loaded.get("model"),
883 Some(&JsonValue::String("opus".to_string()))
884 );
885 assert_eq!(loaded.model(), Some("opus"));
886 assert_eq!(
887 loaded.permission_mode(),
888 Some(ResolvedPermissionMode::WorkspaceWrite)
889 );
890 assert_eq!(
891 loaded
892 .get("env")
893 .and_then(JsonValue::as_object)
894 .expect("env object")
895 .len(),
896 4
897 );
898 assert!(loaded
899 .get("hooks")
900 .and_then(JsonValue::as_object)
901 .expect("hooks object")
902 .contains_key("PreToolUse"));
903 assert!(loaded
904 .get("hooks")
905 .and_then(JsonValue::as_object)
906 .expect("hooks object")
907 .contains_key("PostToolUse"));
908 assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
909 assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
910 assert!(loaded.mcp().get("home").is_some());
911 assert!(loaded.mcp().get("project").is_some());
912
913 fs::remove_dir_all(root).expect("cleanup temp dir");
914 }
915
916 #[test]
917 fn parses_sandbox_config() {
918 let root = temp_dir();
919 let cwd = root.join("project");
920 let home = root.join("home").join(".ternlang");
921 fs::create_dir_all(cwd.join(".ternlang")).expect("project config dir");
922 fs::create_dir_all(&home).expect("home config dir");
923
924 fs::write(
925 cwd.join(".ternlang").join("settings.local.json"),
926 r#"{
927 "sandbox": {
928 "enabled": true,
929 "namespaceRestrictions": false,
930 "networkIsolation": true,
931 "filesystemMode": "allow-list",
932 "allowedMounts": ["logs", "tmp/cache"]
933 }
934 }"#,
935 )
936 .expect("write local settings");
937
938 let loaded = ConfigLoader::new(&cwd, &home)
939 .load()
940 .expect("config should load");
941
942 assert_eq!(loaded.sandbox().enabled, Some(true));
943 assert_eq!(loaded.sandbox().namespace_restrictions, Some(false));
944 assert_eq!(loaded.sandbox().network_isolation, Some(true));
945 assert_eq!(
946 loaded.sandbox().filesystem_mode,
947 Some(FilesystemIsolationMode::AllowList)
948 );
949 assert_eq!(loaded.sandbox().allowed_mounts, vec!["logs", "tmp/cache"]);
950
951 fs::remove_dir_all(root).expect("cleanup temp dir");
952 }
953
954 #[test]
955 fn parses_typed_mcp_and_oauth_config() {
956 let root = temp_dir();
957 let cwd = root.join("project");
958 let home = root.join("home").join(".ternlang");
959 fs::create_dir_all(cwd.join(".ternlang")).expect("project config dir");
960 fs::create_dir_all(&home).expect("home config dir");
961
962 fs::write(
963 home.join("settings.json"),
964 r#"{
965 "mcpServers": {
966 "stdio-server": {
967 "command": "uvx",
968 "args": ["mcp-server"],
969 "env": {"TOKEN": "secret"}
970 },
971 "remote-server": {
972 "type": "http",
973 "url": "https://example.test/mcp",
974 "headers": {"Authorization": "Bearer token"},
975 "headersHelper": "helper.sh",
976 "oauth": {
977 "clientId": "mcp-client",
978 "callbackPort": 7777,
979 "authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
980 "xaa": true
981 }
982 }
983 },
984 "oauth": {
985 "clientId": "runtime-client",
986 "authorizeUrl": "https://console.test/oauth/authorize",
987 "tokenUrl": "https://console.test/oauth/token",
988 "callbackPort": 54545,
989 "manualRedirectUrl": "https://console.test/oauth/callback",
990 "scopes": ["org:read", "user:write"]
991 }
992 }"#,
993 )
994 .expect("write user settings");
995 fs::write(
996 cwd.join(".ternlang").join("settings.local.json"),
997 r#"{
998 "mcpServers": {
999 "remote-server": {
1000 "type": "ws",
1001 "url": "wss://override.test/mcp",
1002 "headers": {"X-Env": "local"}
1003 }
1004 }
1005 }"#,
1006 )
1007 .expect("write local settings");
1008
1009 let loaded = ConfigLoader::new(&cwd, &home)
1010 .load()
1011 .expect("config should load");
1012
1013 let stdio_server = loaded
1014 .mcp()
1015 .get("stdio-server")
1016 .expect("stdio server should exist");
1017 assert_eq!(stdio_server.scope, ConfigSource::User);
1018 assert_eq!(stdio_server.transport(), McpTransport::Stdio);
1019
1020 let remote_server = loaded
1021 .mcp()
1022 .get("remote-server")
1023 .expect("remote server should exist");
1024 assert_eq!(remote_server.scope, ConfigSource::Local);
1025 assert_eq!(remote_server.transport(), McpTransport::Ws);
1026 match &remote_server.config {
1027 McpServerConfig::Ws(config) => {
1028 assert_eq!(config.url, "wss://override.test/mcp");
1029 assert_eq!(
1030 config.headers.get("X-Env").map(String::as_str),
1031 Some("local")
1032 );
1033 }
1034 other => panic!("expected ws config, got {other:?}"),
1035 }
1036
1037 let oauth = loaded.oauth().expect("oauth config should exist");
1038 assert_eq!(oauth.client_id, "runtime-client");
1039 assert_eq!(oauth.callback_port, Some(54_545));
1040 assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
1041
1042 fs::remove_dir_all(root).expect("cleanup temp dir");
1043 }
1044
1045 #[test]
1046 fn rejects_invalid_mcp_server_shapes() {
1047 let root = temp_dir();
1048 let cwd = root.join("project");
1049 let home = root.join("home").join(".ternlang");
1050 fs::create_dir_all(&home).expect("home config dir");
1051 fs::create_dir_all(&cwd).expect("project dir");
1052 fs::write(
1053 home.join("settings.json"),
1054 r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
1055 )
1056 .expect("write broken settings");
1057
1058 let error = ConfigLoader::new(&cwd, &home)
1059 .load()
1060 .expect_err("config should fail");
1061 assert!(error
1062 .to_string()
1063 .contains("mcpServers.broken: missing string field url"));
1064
1065 fs::remove_dir_all(root).expect("cleanup temp dir");
1066 }
1067}
1068fn parse_optional_providers(root: &JsonValue) -> Result<BTreeMap<String, ProviderConfig>, ConfigError> {
1069 let Some(object) = root.as_object() else {
1070 return Ok(BTreeMap::new());
1071 };
1072 let Some(providers_value) = object.get("providers") else {
1073 return Ok(BTreeMap::new());
1074 };
1075 let providers = expect_object(providers_value, "merged settings.providers")?;
1076 let mut result = BTreeMap::new();
1077 for (name, value) in providers {
1078 let provider = expect_object(value, &format!("merged settings.providers.{name}"))?;
1079 result.insert(
1080 name.clone(),
1081 ProviderConfig {
1082 api_key: optional_string(provider, "api_key", "merged settings.providers")?
1083 .map(str::to_string),
1084 model: optional_string(provider, "model", "merged settings.providers")?
1085 .map(str::to_string),
1086 },
1087 );
1088 }
1089 Ok(result)
1090}
1091
1092fn parse_optional_default_provider(root: &JsonValue) -> Option<String> {
1093 root.as_object()
1094 .and_then(|object| object.get("default_provider"))
1095 .and_then(JsonValue::as_str)
1096 .map(ToOwned::to_owned)
1097}