Skip to main content

albert_runtime/
config.rs

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}