1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum CompactionTokenField {
26 ReserveTokens,
28 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#[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 #[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 #[must_use]
80 pub fn merged(&self) -> &JsonObject {
81 &self.merged
82 }
83
84 #[must_use]
86 pub fn global(&self) -> &JsonObject {
87 &self.global
88 }
89
90 #[must_use]
92 pub fn project(&self) -> &JsonObject {
93 &self.project
94 }
95
96 #[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 #[must_use]
111 pub fn value(&self, path: &[&str]) -> Option<&Value> {
112 get_path(&self.merged, path)
113 }
114
115 #[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 #[must_use]
145 pub fn default_provider(&self) -> Option<&str> {
146 self.str_at(&["defaultProvider"])
147 }
148
149 #[must_use]
151 pub fn default_model(&self) -> Option<&str> {
152 self.str_at(&["defaultModel"])
153 }
154
155 #[must_use]
158 pub fn default_thinking_level(&self) -> Option<ThinkingLevel> {
159 self.value(&["defaultThinkingLevel"]).and_then(parse_enum)
160 }
161
162 #[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 #[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 #[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 #[must_use]
199 pub fn enabled_models(&self) -> Option<Vec<String>> {
200 self.value(&["enabledModels"]).and_then(string_array)
201 }
202
203 #[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 #[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 #[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 #[must_use]
230 pub fn session_dir(&self) -> Option<PathBuf> {
231 self.path_at("sessionDir")
232 }
233
234 #[must_use]
236 pub fn compaction_enabled(&self) -> bool {
237 self.bool_at(&["compaction", "enabled"], defaults::COMPACTION_ENABLED)
238 }
239
240 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 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 #[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 #[must_use]
322 pub fn retry_enabled(&self) -> bool {
323 self.bool_at(&["retry", "enabled"], defaults::RETRY_ENABLED)
324 }
325
326 #[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 #[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 #[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 #[must_use]
365 pub fn http_proxy(&self) -> Option<&str> {
366 self.global.get("httpProxy").and_then(Value::as_str)
367 }
368
369 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 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 #[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 #[must_use]
404 pub fn show_cache_miss_notices(&self) -> bool {
405 self.bool_at(&["showCacheMissNotices"], defaults::SHOW_CACHE_MISS_NOTICES)
406 }
407
408 #[must_use]
410 pub fn shell_path(&self) -> Option<PathBuf> {
411 self.path_at("shellPath")
412 }
413
414 #[must_use]
416 pub fn shell_command_prefix(&self) -> Option<&str> {
417 self.str_at(&["shellCommandPrefix"])
418 }
419
420 #[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 #[must_use]
431 pub fn enable_skill_commands(&self) -> bool {
432 self.bool_at(&["enableSkillCommands"], defaults::ENABLE_SKILL_COMMANDS)
433 }
434
435 #[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 #[must_use]
446 pub fn device_id(&self) -> Option<&str> {
447 self.global.get("deviceId").and_then(Value::as_str)
448 }
449
450 #[must_use]
452 pub fn packages(&self) -> Vec<PackageSource> {
453 parse_packages(self.merged.get("packages"))
454 }
455
456 #[must_use]
458 pub fn packages_in(&self, scope: SettingsScope) -> Vec<PackageSource> {
459 parse_packages(self.layer(scope).get("packages"))
460 }
461
462 #[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 #[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 #[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 #[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 #[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 #[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 #[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#[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}