Skip to main content

oino_settings/
view.rs

1//! [`Settings`]: an immutable, typed view over the global, project and merged layers
2//! (the settings-manager getters).
3
4use std::path::{Path, PathBuf};
5
6use indexmap::IndexMap;
7use oino_ai::{ThinkingBudgets, ThinkingLevel, Transport};
8use serde_json::Value;
9
10use crate::error::{InvalidSettingError, SettingsScope};
11use crate::json::{
12    JsonObject, as_non_negative_u64, as_safe_non_negative_integer, deep_merge_objects,
13    deep_merge_settings, get_path, js_string, string_array,
14};
15use crate::paths::normalize_path;
16use crate::tools::resolve_default_tools;
17use crate::types::{
18    BranchSummarySettings, CacheWarmingMode, CompactionSettings, ImageSettings, PackageSource,
19    ProjectTrustDefault, ProviderRetrySettings, QueueMode, ResourceKind, RetrySettings,
20    ServeSettings, UiDensity, defaults, parse_enum,
21};
22
23/// Compaction token fields that support per-model overrides.
24#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum CompactionTokenField {
26    /// `reserveTokens`.
27    ReserveTokens,
28    /// `keepRecentTokens`.
29    KeepRecentTokens,
30}
31
32impl CompactionTokenField {
33    const fn key(self) -> &'static str {
34        match self {
35            Self::ReserveTokens => "reserveTokens",
36            Self::KeepRecentTokens => "keepRecentTokens",
37        }
38    }
39
40    const fn default_value(self) -> u64 {
41        match self {
42            Self::ReserveTokens => defaults::COMPACTION_RESERVE_TOKENS,
43            Self::KeepRecentTokens => defaults::COMPACTION_KEEP_RECENT_TOKENS,
44        }
45    }
46}
47
48/// Typed, layered settings snapshot. Cheap to clone relative to file I/O; hand it to blocking
49/// tasks instead of the manager.
50#[derive(Clone, Debug, Default, PartialEq)]
51pub struct Settings {
52    global: JsonObject,
53    project: JsonObject,
54    overrides: JsonObject,
55    merged: JsonObject,
56    home: Option<PathBuf>,
57}
58
59impl Settings {
60    /// Build a view from raw layers (`project` overrides `global`, then `overrides`).
61    #[must_use]
62    pub fn from_layers(
63        global: JsonObject,
64        project: JsonObject,
65        overrides: JsonObject,
66        home: Option<PathBuf>,
67    ) -> Self {
68        let merged = deep_merge_settings(&deep_merge_settings(&global, &project), &overrides);
69        Self {
70            global,
71            project,
72            overrides,
73            merged,
74            home,
75        }
76    }
77
78    /// The effective object.
79    #[must_use]
80    pub fn merged(&self) -> &JsonObject {
81        &self.merged
82    }
83
84    /// The global layer.
85    #[must_use]
86    pub fn global(&self) -> &JsonObject {
87        &self.global
88    }
89
90    /// The project layer; empty when untrusted.
91    #[must_use]
92    pub fn project(&self) -> &JsonObject {
93        &self.project
94    }
95
96    /// One layer.
97    #[must_use]
98    pub fn layer(&self, scope: SettingsScope) -> &JsonObject {
99        match scope {
100            SettingsScope::Global => &self.global,
101            SettingsScope::Project => &self.project,
102        }
103    }
104
105    pub(crate) fn overrides(&self) -> &JsonObject {
106        &self.overrides
107    }
108
109    /// The effective value at a key path.
110    #[must_use]
111    pub fn value(&self, path: &[&str]) -> Option<&Value> {
112        get_path(&self.merged, path)
113    }
114
115    /// An untyped (frontend-owned) key such as `theme` or `markdown`, effective value.
116    #[must_use]
117    pub fn frontend(&self, key: &str) -> Option<Value> {
118        self.merged.get(key).cloned()
119    }
120
121    fn str_at(&self, path: &[&str]) -> Option<&str> {
122        self.value(path).and_then(Value::as_str)
123    }
124
125    fn bool_at(&self, path: &[&str], default: bool) -> bool {
126        self.value(path).and_then(Value::as_bool).unwrap_or(default)
127    }
128
129    fn u64_at(&self, path: &[&str], default: u64) -> u64 {
130        self.value(path)
131            .and_then(as_non_negative_u64)
132            .unwrap_or(default)
133    }
134
135    fn path_at(&self, key: &str) -> Option<PathBuf> {
136        self.merged
137            .get(key)
138            .and_then(Value::as_str)
139            .filter(|path| !path.is_empty())
140            .map(|path| normalize_path(path, self.home.as_deref()))
141    }
142
143    /// `defaultProvider`.
144    #[must_use]
145    pub fn default_provider(&self) -> Option<&str> {
146        self.str_at(&["defaultProvider"])
147    }
148
149    /// `defaultModel`.
150    #[must_use]
151    pub fn default_model(&self) -> Option<&str> {
152        self.str_at(&["defaultModel"])
153    }
154
155    /// `defaultThinkingLevel` (unset → `None`; the runtime then uses
156    /// [`defaults::THINKING_LEVEL`]).
157    #[must_use]
158    pub fn default_thinking_level(&self) -> Option<ThinkingLevel> {
159        self.value(&["defaultThinkingLevel"]).and_then(parse_enum)
160    }
161
162    /// `modelThinkingLevels["provider/modelId"]`.
163    #[must_use]
164    pub fn model_thinking_level(&self, provider: &str, model_id: &str) -> Option<ThinkingLevel> {
165        let key = format!("{provider}/{model_id}");
166        self.value(&["modelThinkingLevels", &key])
167            .and_then(parse_enum)
168    }
169
170    /// Every valid `modelThinkingLevels` entry.
171    #[must_use]
172    pub fn model_thinking_levels(&self) -> IndexMap<String, ThinkingLevel> {
173        self.value(&["modelThinkingLevels"])
174            .and_then(Value::as_object)
175            .map(|levels| {
176                levels
177                    .iter()
178                    .filter_map(|(key, level)| parse_enum(level).map(|level| (key.clone(), level)))
179                    .collect()
180            })
181            .unwrap_or_default()
182    }
183
184    /// `thinkingBudgets`.
185    #[must_use]
186    pub fn thinking_budgets(&self) -> Option<ThinkingBudgets> {
187        let budgets = self.value(&["thinkingBudgets"])?.as_object()?;
188        let field = |key: &str| budgets.get(key).and_then(as_non_negative_u64);
189        Some(ThinkingBudgets {
190            minimal: field("minimal"),
191            low: field("low"),
192            medium: field("medium"),
193            high: field("high"),
194        })
195    }
196
197    /// `enabledModels` patterns.
198    #[must_use]
199    pub fn enabled_models(&self) -> Option<Vec<String>> {
200        self.value(&["enabledModels"]).and_then(string_array)
201    }
202
203    /// `steeringMode` (default `one-at-a-time`).
204    #[must_use]
205    pub fn steering_mode(&self) -> QueueMode {
206        self.value(&["steeringMode"])
207            .and_then(parse_enum)
208            .unwrap_or_default()
209    }
210
211    /// `followUpMode` (default `one-at-a-time`).
212    #[must_use]
213    pub fn follow_up_mode(&self) -> QueueMode {
214        self.value(&["followUpMode"])
215            .and_then(parse_enum)
216            .unwrap_or_default()
217    }
218
219    /// Resolved `defaultTools` selection; `None` when no layer sets it.
220    #[must_use]
221    pub fn default_tools(&self) -> Option<Vec<String>> {
222        let tools = self.merged.get("defaultTools")?;
223        Some(resolve_default_tools(
224            &string_array(tools).unwrap_or_default(),
225        ))
226    }
227
228    /// `sessionDir`, tilde-expanded (relative paths stay relative to the caller's cwd).
229    #[must_use]
230    pub fn session_dir(&self) -> Option<PathBuf> {
231        self.path_at("sessionDir")
232    }
233
234    /// `compaction.enabled` (default `true`).
235    #[must_use]
236    pub fn compaction_enabled(&self) -> bool {
237        self.bool_at(&["compaction", "enabled"], defaults::COMPACTION_ENABLED)
238    }
239
240    /// One compaction token setting: model override, then ordinary value, then default
241    ///.
242    ///
243    /// # Errors
244    ///
245    /// Validation errors for non-integer values or non-object model overrides.
246    pub fn compaction_token(
247        &self,
248        field: CompactionTokenField,
249        model: Option<(&str, &str)>,
250    ) -> Result<u64, InvalidSettingError> {
251        let key = field.key();
252        let ordinary = self.value(&["compaction", key]);
253        let ordinary = match ordinary {
254            None => None,
255            Some(value) => Some(as_safe_non_negative_integer(value).ok_or_else(|| {
256                InvalidSettingError::new(format!(
257                    "Invalid compaction.{key} setting: {}. Expected a non-negative safe integer.",
258                    js_string(value)
259                ))
260            })?),
261        };
262        let Some((provider, id)) = model else {
263            return Ok(ordinary.unwrap_or(field.default_value()));
264        };
265        let model_key = format!("{provider}/{id}");
266        let entry = self.value(&["compaction", "modelOverrides", &model_key]);
267        let override_value = match entry {
268            None => None,
269            Some(Value::Object(entry)) => match entry.get(key) {
270                None => None,
271                Some(value) => Some(as_safe_non_negative_integer(value).ok_or_else(|| {
272                    InvalidSettingError::new(format!(
273                        "Invalid compaction.modelOverrides[\"{model_key}\"].{key} setting: {}. Expected a non-negative safe integer.",
274                        js_string(value)
275                    ))
276                })?),
277            },
278            Some(other) => {
279                return Err(InvalidSettingError::new(format!(
280                    "Invalid compaction.modelOverrides[\"{model_key}\"] setting: {}. Expected an object.",
281                    js_string(other)
282                )));
283            }
284        };
285        Ok(override_value.or(ordinary).unwrap_or(field.default_value()))
286    }
287
288    /// Resolved compaction settings for an optional `(provider, modelId)`.
289    ///
290    /// # Errors
291    ///
292    /// See [`Self::compaction_token`].
293    pub fn compaction_settings(
294        &self,
295        model: Option<(&str, &str)>,
296    ) -> Result<CompactionSettings, InvalidSettingError> {
297        Ok(CompactionSettings {
298            enabled: self.compaction_enabled(),
299            reserve_tokens: self.compaction_token(CompactionTokenField::ReserveTokens, model)?,
300            keep_recent_tokens: self
301                .compaction_token(CompactionTokenField::KeepRecentTokens, model)?,
302        })
303    }
304
305    /// `branchSummary.*`.
306    #[must_use]
307    pub fn branch_summary_settings(&self) -> BranchSummarySettings {
308        BranchSummarySettings {
309            reserve_tokens: self.u64_at(
310                &["branchSummary", "reserveTokens"],
311                defaults::BRANCH_SUMMARY_RESERVE_TOKENS,
312            ),
313            skip_prompt: self.bool_at(
314                &["branchSummary", "skipPrompt"],
315                defaults::BRANCH_SUMMARY_SKIP_PROMPT,
316            ),
317        }
318    }
319
320    /// `retry.enabled` (default `true`).
321    #[must_use]
322    pub fn retry_enabled(&self) -> bool {
323        self.bool_at(&["retry", "enabled"], defaults::RETRY_ENABLED)
324    }
325
326    /// `retry.{enabled, maxRetries, baseDelayMs, maxAgentDelayMs}`.
327    #[must_use]
328    pub fn retry_settings(&self) -> RetrySettings {
329        RetrySettings {
330            enabled: self.retry_enabled(),
331            max_retries: self.u64_at(&["retry", "maxRetries"], defaults::RETRY_MAX_RETRIES),
332            base_delay_ms: self.u64_at(&["retry", "baseDelayMs"], defaults::RETRY_BASE_DELAY_MS),
333            max_agent_delay_ms: self.u64_at(
334                &["retry", "maxAgentDelayMs"],
335                defaults::RETRY_MAX_AGENT_DELAY_MS,
336            ),
337        }
338    }
339
340    /// `retry.provider.*`.
341    #[must_use]
342    pub fn provider_retry_settings(&self) -> ProviderRetrySettings {
343        let field = |key: &str| {
344            self.value(&["retry", "provider", key])
345                .and_then(as_non_negative_u64)
346        };
347        ProviderRetrySettings {
348            timeout_ms: field("timeoutMs"),
349            max_retries: field("maxRetries"),
350            max_retry_delay_ms: field("maxRetryDelayMs")
351                .unwrap_or(defaults::RETRY_PROVIDER_MAX_RETRY_DELAY_MS),
352        }
353    }
354
355    /// `transport` (default `auto`).
356    #[must_use]
357    pub fn transport(&self) -> Transport {
358        self.value(&["transport"])
359            .and_then(parse_enum)
360            .unwrap_or(Transport::Auto)
361    }
362
363    /// `httpProxy`, global layer only (reads `getGlobalSettings().httpProxy`).
364    #[must_use]
365    pub fn http_proxy(&self) -> Option<&str> {
366        self.global.get("httpProxy").and_then(Value::as_str)
367    }
368
369    /// `httpIdleTimeoutMs` (`0` = disabled; default 300000).
370    ///
371    /// # Errors
372    ///
373    /// `Invalid httpIdleTimeoutMs setting: …` for unparsable values.
374    pub fn http_idle_timeout_ms(&self) -> Result<u64, InvalidSettingError> {
375        Ok(
376            parse_timeout_setting(self.merged.get("httpIdleTimeoutMs"), "httpIdleTimeoutMs")?
377                .unwrap_or(defaults::HTTP_IDLE_TIMEOUT_MS),
378        )
379    }
380
381    /// `websocketConnectTimeoutMs` (`None` = provider default).
382    ///
383    /// # Errors
384    ///
385    /// `Invalid websocketConnectTimeoutMs setting: …` for unparsable values.
386    pub fn websocket_connect_timeout_ms(&self) -> Result<Option<u64>, InvalidSettingError> {
387        parse_timeout_setting(
388            self.merged.get("websocketConnectTimeoutMs"),
389            "websocketConnectTimeoutMs",
390        )
391    }
392
393    /// `cacheWarming`, global layer only (default `streaming`).
394    #[must_use]
395    pub fn cache_warming_mode(&self) -> CacheWarmingMode {
396        self.global
397            .get("cacheWarming")
398            .and_then(parse_enum)
399            .unwrap_or_default()
400    }
401
402    /// `showCacheMissNotices` (default `false`).
403    #[must_use]
404    pub fn show_cache_miss_notices(&self) -> bool {
405        self.bool_at(&["showCacheMissNotices"], defaults::SHOW_CACHE_MISS_NOTICES)
406    }
407
408    /// `shellPath`, tilde-expanded.
409    #[must_use]
410    pub fn shell_path(&self) -> Option<PathBuf> {
411        self.path_at("shellPath")
412    }
413
414    /// `shellCommandPrefix`.
415    #[must_use]
416    pub fn shell_command_prefix(&self) -> Option<&str> {
417        self.str_at(&["shellCommandPrefix"])
418    }
419
420    /// `images.*`.
421    #[must_use]
422    pub fn image_settings(&self) -> ImageSettings {
423        ImageSettings {
424            auto_resize: self.bool_at(&["images", "autoResize"], defaults::IMAGES_AUTO_RESIZE),
425            block_images: self.bool_at(&["images", "blockImages"], defaults::IMAGES_BLOCK_IMAGES),
426        }
427    }
428
429    /// `enableSkillCommands` (default `true`).
430    #[must_use]
431    pub fn enable_skill_commands(&self) -> bool {
432        self.bool_at(&["enableSkillCommands"], defaults::ENABLE_SKILL_COMMANDS)
433    }
434
435    /// `defaultProjectTrust`, global layer only (default `ask`).
436    #[must_use]
437    pub fn default_project_trust(&self) -> ProjectTrustDefault {
438        self.global
439            .get("defaultProjectTrust")
440            .and_then(parse_enum)
441            .unwrap_or_default()
442    }
443
444    /// `deviceId`, global layer only.
445    #[must_use]
446    pub fn device_id(&self) -> Option<&str> {
447        self.global.get("deviceId").and_then(Value::as_str)
448    }
449
450    /// Effective `packages` (project replaces global).
451    #[must_use]
452    pub fn packages(&self) -> Vec<PackageSource> {
453        parse_packages(self.merged.get("packages"))
454    }
455
456    /// `packages` of one layer (resolution combines both layers, project first).
457    #[must_use]
458    pub fn packages_in(&self, scope: SettingsScope) -> Vec<PackageSource> {
459        parse_packages(self.layer(scope).get("packages"))
460    }
461
462    /// Effective resource array (project replaces global).
463    #[must_use]
464    pub fn resource_paths(&self, kind: ResourceKind) -> Vec<String> {
465        self.merged
466            .get(kind.key())
467            .and_then(string_array)
468            .unwrap_or_default()
469    }
470
471    /// The raw entries of one layer's resource array. Resource resolution combines both
472    /// layers: project entries resolve against `<cwd>/.oino`, global ones against the agent dir.
473    #[must_use]
474    pub fn resource_entries(&self, kind: ResourceKind, scope: SettingsScope) -> Vec<String> {
475        self.layer(scope)
476            .get(kind.key())
477            .and_then(string_array)
478            .unwrap_or_default()
479    }
480
481    /// `extensionSettings.<namespace>` layered as `defaults ⊕ global ⊕ project` (D11).
482    #[must_use]
483    pub fn extension_settings(&self, namespace: &str, defaults: Option<&Value>) -> Value {
484        let layer = |object: &JsonObject| {
485            get_path(object, &["extensionSettings", namespace])
486                .and_then(Value::as_object)
487                .cloned()
488                .unwrap_or_default()
489        };
490        let base = defaults
491            .and_then(Value::as_object)
492            .cloned()
493            .unwrap_or_default();
494        let merged = deep_merge_objects(&base, &layer(&self.global));
495        let merged = deep_merge_objects(&merged, &layer(&self.project));
496        Value::Object(deep_merge_objects(&merged, &layer(&self.overrides)))
497    }
498
499    /// `disabledExtensions` (default `[]`).
500    #[must_use]
501    pub fn disabled_extensions(&self) -> Vec<String> {
502        self.merged
503            .get("disabledExtensions")
504            .and_then(string_array)
505            .unwrap_or_default()
506    }
507
508    /// `serve.*` (defaults `127.0.0.1:7420`, `true`, `false`).
509    #[must_use]
510    pub fn serve_settings(&self) -> ServeSettings {
511        ServeSettings {
512            bind: self
513                .str_at(&["serve", "bind"])
514                .unwrap_or(defaults::SERVE_BIND)
515                .to_owned(),
516            tls: self.bool_at(&["serve", "tls"], defaults::SERVE_TLS),
517            allow_remote: self.bool_at(&["serve", "allowRemote"], defaults::SERVE_ALLOW_REMOTE),
518        }
519    }
520
521    /// `ui.density` (default `regular`).
522    #[must_use]
523    pub fn ui_density(&self) -> UiDensity {
524        self.value(&["ui", "density"])
525            .and_then(parse_enum)
526            .unwrap_or_default()
527    }
528
529    /// Home directory used for `~` expansion.
530    #[must_use]
531    pub fn home(&self) -> Option<&Path> {
532        self.home.as_deref()
533    }
534}
535
536fn parse_packages(value: Option<&Value>) -> Vec<PackageSource> {
537    value
538        .and_then(Value::as_array)
539        .map(|items| {
540            items
541                .iter()
542                .filter_map(|item| serde_json::from_value(item.clone()).ok())
543                .collect()
544        })
545        .unwrap_or_default()
546}
547
548/// Numbers ≥ 0 (floored), numeric strings, `"disabled"` → 0.
549#[must_use]
550pub fn parse_timeout_ms(value: &Value) -> Option<u64> {
551    match value {
552        Value::String(text) => {
553            let trimmed = text.trim();
554            if trimmed.eq_ignore_ascii_case("disabled") {
555                return Some(0);
556            }
557            if trimmed.is_empty() {
558                return None;
559            }
560            let number: f64 = trimmed.parse().ok()?;
561            serde_json::Number::from_f64(number)
562                .and_then(|n| as_non_negative_u64(&Value::Number(n)))
563        }
564        Value::Number(_) => as_non_negative_u64(value),
565        _ => None,
566    }
567}
568
569fn parse_timeout_setting(
570    value: Option<&Value>,
571    name: &str,
572) -> Result<Option<u64>, InvalidSettingError> {
573    match value {
574        None => Ok(None),
575        Some(value) => parse_timeout_ms(value).map(Some).ok_or_else(|| {
576            InvalidSettingError::new(format!("Invalid {name} setting: {}", js_string(value)))
577        }),
578    }
579}