1use std::path::{Path, PathBuf};
9
10use serde::Deserialize as _;
11use serde_json::{Map, Value};
12use yapi_ai::auth::lock::{FileLock, write_atomic};
13use yapi_ai::http::is_truthy;
14use yapi_types::config::ConfigFile;
15use yapi_types::settings::{CacheWarming, Settings};
16
17use crate::config::PROJECT_DIR;
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum Scope {
22 Global,
24 Project,
26}
27
28#[derive(Debug, thiserror::Error)]
30pub enum SettingsError {
31 #[error("{path}: {source}")]
33 Io {
34 path: PathBuf,
36 source: std::io::Error,
38 },
39 #[error("Failed to parse {path}: {message}")]
41 Parse {
42 path: PathBuf,
44 message: String,
46 },
47}
48
49#[derive(Clone, Debug, Default)]
51pub struct SettingsManager {
52 project_dir: PathBuf,
53 global_path: PathBuf,
54 project_path: PathBuf,
55 global: Map<String, Value>,
56 project: Map<String, Value>,
57 project_trusted: bool,
58 document: Map<String, Value>,
60 merged: Settings,
61 broken: [bool; 2],
64 errors: Vec<String>,
65}
66
67fn read_document(path: &Path) -> Result<Map<String, Value>, SettingsError> {
68 let text = match std::fs::read_to_string(path) {
69 Ok(text) => text,
70 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()),
71 Err(source) => {
72 return Err(SettingsError::Io {
73 path: path.to_path_buf(),
74 source,
75 });
76 }
77 };
78 let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
79 if text.trim().is_empty() {
80 return Ok(Map::new());
81 }
82 match serde_json::from_str::<Value>(text) {
83 Ok(Value::Object(mut map)) => {
84 migrate(&mut map);
85 Ok(map)
86 }
87 Ok(_) => Err(SettingsError::Parse {
88 path: path.to_path_buf(),
89 message: "expected an object".into(),
90 }),
91 Err(err) => Err(SettingsError::Parse {
92 path: path.to_path_buf(),
93 message: err.to_string(),
94 }),
95 }
96}
97
98fn migrate(document: &mut Map<String, Value>) {
103 if !document.contains_key("steeringMode")
104 && let Some(mode) = document.shift_remove("queueMode")
105 {
106 document.insert("steeringMode".into(), mode);
107 }
108 if let Some(&Value::Bool(websockets)) = document.get("websockets")
109 && !document.contains_key("transport")
110 {
111 let transport = if websockets { "websocket" } else { "sse" };
112 document.insert("transport".into(), Value::from(transport));
113 document.shift_remove("websockets");
114 }
115 if let Some(Value::Object(skills)) = document.get("skills").cloned() {
116 if let Some(enable) = skills.get("enableSkillCommands")
117 && !document.contains_key("enableSkillCommands")
118 {
119 document.insert("enableSkillCommands".into(), enable.clone());
120 }
121 match skills.get("customDirectories") {
122 Some(Value::Array(directories)) if !directories.is_empty() => {
123 document.insert("skills".into(), Value::Array(directories.clone()));
124 }
125 _ => {
126 document.shift_remove("skills");
127 }
128 }
129 }
130 if let Some(Value::Object(retry)) = document.get_mut("retry") {
131 let provider = retry.get("provider").and_then(Value::as_object).cloned();
132 let delay_unset = provider
133 .as_ref()
134 .is_none_or(|provider| provider.get("maxRetryDelayMs").is_none_or(Value::is_null));
135 if let Some(delay) = retry.get("maxDelayMs").filter(|delay| delay.is_number())
136 && delay_unset
137 {
138 let mut provider = provider.unwrap_or_default();
139 provider.insert("maxRetryDelayMs".into(), delay.clone());
140 retry.insert("provider".into(), Value::Object(provider));
141 }
142 retry.shift_remove("maxDelayMs");
143 }
144}
145
146pub fn js_string(value: &Value) -> String {
148 match value {
149 Value::String(text) => text.clone(),
150 Value::Array(items) => items
151 .iter()
152 .map(|item| match item {
153 Value::Null => String::new(),
154 item => js_string(item),
155 })
156 .collect::<Vec<_>>()
157 .join(","),
158 Value::Object(_) => "[object Object]".to_owned(),
159 other => yapi_types::json::stringify(other),
160 }
161}
162
163fn deep_merge(base: &Map<String, Value>, overrides: &Map<String, Value>) -> Map<String, Value> {
165 let mut result = base.clone();
166 for (key, value) in overrides {
167 let merged = match (base.get(key), value) {
168 (Some(Value::Object(base)), Value::Object(over)) => {
169 Value::Object(deep_merge(base, over))
170 }
171 _ => value.clone(),
172 };
173 result.insert(key.clone(), merged);
174 }
175 result
176}
177
178fn is_tool_modifier(entry: &str) -> bool {
179 entry.starts_with('+') || entry.starts_with('-')
180}
181
182fn merge_default_tools(base: Option<&Value>, overrides: Option<&Value>) -> Option<Value> {
185 let Some(overrides) = overrides else {
186 return base.cloned();
187 };
188 match (base.and_then(Value::as_array), overrides.as_array()) {
189 (Some(base), Some(list))
190 if list
191 .iter()
192 .all(|entry| entry.as_str().is_some_and(is_tool_modifier)) =>
193 {
194 Some(Value::Array(base.iter().chain(list).cloned().collect()))
195 }
196 _ => Some(overrides.clone()),
197 }
198}
199
200fn resolve_default_tools(entries: &[&str]) -> Vec<String> {
204 let mut tools: Vec<String> = entries
205 .iter()
206 .filter(|entry| !is_tool_modifier(entry))
207 .map(|&entry| entry.to_owned())
208 .collect();
209 if tools.is_empty() && !entries.is_empty() {
210 tools = crate::tools::DEFAULT_TOOLS.map(str::to_owned).to_vec();
211 }
212 for entry in entries.iter().filter(|entry| is_tool_modifier(entry)) {
213 let name = &entry[1..];
214 let index = tools.iter().position(|tool| tool == name);
215 match index {
216 None if entry.starts_with('+') && !name.is_empty() => tools.push(name.to_owned()),
217 Some(index) if entry.starts_with('-') => {
218 tools.remove(index);
219 }
220 _ => {}
221 }
222 }
223 tools
224}
225
226fn reads_at(path: &[String], value: &Value) -> bool {
228 let nested = path.iter().rev().fold(value.clone(), |inner, key| {
229 Value::Object(Map::from_iter([(key.clone(), inner)]))
230 });
231 Settings::deserialize(nested).is_ok()
232}
233
234fn valid_fields(path: &mut Vec<String>, object: Map<String, Value>) -> Map<String, Value> {
238 let mut valid = Map::new();
239 for (key, value) in object {
240 path.push(key.clone());
241 let kept = match value {
242 value if reads_at(path, &value) => Some(value),
243 Value::Object(inner) => {
244 Some(Value::Object(valid_fields(path, inner))).filter(|inner| reads_at(path, inner))
245 }
246 _ => None,
247 };
248 path.pop();
249 if let Some(value) = kept {
250 valid.insert(key, value);
251 }
252 }
253 valid
254}
255
256impl SettingsManager {
257 pub fn load(agent_dir: &Path, project_dir: &Path, project_trusted: bool) -> SettingsManager {
260 let global_path = agent_dir.join(ConfigFile::Settings.file_name());
261 let project_path = project_dir
262 .join(PROJECT_DIR)
263 .join(ConfigFile::Settings.file_name());
264 let mut errors = Vec::new();
265 let mut broken = [false; 2];
266 let mut read = |path: &Path, scope: usize| match read_document(path) {
268 Ok(document) => document,
269 Err(error) => {
270 let message = match error {
271 SettingsError::Parse { message, .. } => message,
272 SettingsError::Io { source, .. } => source.to_string(),
273 };
274 errors.push(format!(
275 "Invalid settings file {}: {message}",
276 path.display()
277 ));
278 broken[scope] = true;
279 Map::new()
280 }
281 };
282 let global = read(&global_path, 0);
283 let project = if project_trusted {
284 read(&project_path, 1)
285 } else {
286 Map::new()
287 };
288 let mut manager = SettingsManager {
289 project_dir: project_dir.to_path_buf(),
290 global_path,
291 project_path,
292 global,
293 project,
294 project_trusted,
295 document: Map::new(),
296 merged: Settings::default(),
297 broken,
298 errors,
299 };
300 manager.remerge();
301 manager
302 }
303
304 pub fn in_memory() -> SettingsManager {
306 SettingsManager::default()
307 }
308
309 pub fn errors(&self) -> &[String] {
311 &self.errors
312 }
313
314 fn remerge(&mut self) {
315 let mut merged = deep_merge(&self.global, &self.project);
316 if let Some(tools) = merge_default_tools(
317 self.global.get("defaultTools"),
318 self.project.get("defaultTools"),
319 ) {
320 merged.insert("defaultTools".into(), tools);
321 }
322 self.document = merged.clone();
323 for field in ["compaction", "retry"] {
326 if let Some(Value::Object(object)) = merged.get_mut(field)
327 && let Some(enabled) = object.get("enabled").filter(|value| !value.is_boolean())
328 {
329 match enabled {
330 Value::Null => object.shift_remove("enabled"),
331 value => object.insert("enabled".into(), is_truthy(value).into()),
332 };
333 }
334 }
335 self.merged = Settings::deserialize(Value::Object(merged.clone())).unwrap_or_else(|_| {
337 let valid = Value::Object(valid_fields(&mut Vec::new(), merged));
338 Settings::deserialize(valid).unwrap_or_default()
339 });
340 }
341
342 pub fn settings(&self) -> &Settings {
344 &self.merged
345 }
346
347 pub fn default_tools(&self) -> Option<Vec<String>> {
351 let merged = merge_default_tools(
352 self.global.get("defaultTools"),
353 self.project.get("defaultTools"),
354 )?;
355 let entries: Vec<&str> = match &merged {
356 Value::Array(entries) => entries.iter().filter_map(Value::as_str).collect(),
357 _ => Vec::new(),
358 };
359 Some(resolve_default_tools(&entries))
360 }
361
362 pub fn merged_document(&self) -> &Map<String, Value> {
365 &self.document
366 }
367
368 pub fn default_thinking_level(&self) -> Option<yapi_types::message::ThinkingLevel> {
371 let value = self
372 .project
373 .get("defaultThinkingLevel")
374 .or_else(|| self.global.get("defaultThinkingLevel"))?;
375 Some(
376 value
377 .as_str()
378 .and_then(yapi_types::message::ThinkingLevel::parse)
379 .unwrap_or(yapi_types::message::ThinkingLevel::Off),
380 )
381 }
382
383 pub fn http_idle_timeout_ms(&self) -> Result<u64, String> {
386 Ok(self.timeout_ms("httpIdleTimeoutMs")?.unwrap_or(300_000))
387 }
388
389 pub fn websocket_connect_timeout_ms(&self) -> Result<Option<u64>, String> {
392 self.timeout_ms("websocketConnectTimeoutMs")
393 }
394
395 pub fn provider_timeout_ms(&self) -> Option<f64> {
398 let read = |document: &Map<String, Value>| {
399 document
400 .get("retry")?
401 .get("provider")?
402 .get("timeoutMs")?
403 .as_f64()
404 };
405 read(&self.project).or_else(|| read(&self.global))
406 }
407
408 fn timeout_ms(&self, key: &str) -> Result<Option<u64>, String> {
412 let Some(value) = self.project.get(key).or_else(|| self.global.get(key)) else {
413 return Ok(None);
414 };
415 let number = match value {
416 Value::String(text) if text.trim().eq_ignore_ascii_case("disabled") => Some(0.0),
417 Value::String(text) if text.trim().is_empty() => None,
418 Value::String(text) => text.trim().parse::<f64>().ok(),
419 Value::Number(number) => number.as_f64(),
420 _ => None,
421 };
422 number
423 .filter(|ms| ms.is_finite() && *ms >= 0.0)
424 .map(|ms| Some(ms.floor() as u64))
425 .ok_or_else(|| format!("Invalid {key} setting: {}", js_string(value)))
426 }
427
428 pub fn compaction_error(&self, model: Option<&yapi_types::model::Model>) -> Option<String> {
432 const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
433 let merged = deep_merge(&self.global, &self.project);
434 let compaction = merged.get("compaction");
435 let key = model.map(|model| format!("{}/{}", model.provider, model.id));
436 let entry = key
437 .as_ref()
438 .and_then(|key| compaction?.get("modelOverrides")?.get(key));
439 let invalid = |value: &&Value| {
440 !value
441 .as_f64()
442 .is_some_and(|n| n.fract() == 0.0 && (0.0..=MAX_SAFE_INTEGER).contains(&n))
443 };
444 let expected = "Expected a non-negative safe integer.";
445 for field in ["reserveTokens", "keepRecentTokens"] {
446 if let Some(value) = compaction.and_then(|c| c.get(field)).filter(invalid) {
447 let value = js_string(value);
448 return Some(format!(
449 "Invalid compaction.{field} setting: {value}. {expected}"
450 ));
451 }
452 let (Some(key), Some(entry)) = (&key, entry) else {
453 continue;
454 };
455 let name = format!("compaction.modelOverrides[\"{key}\"]");
456 if !entry.is_object() {
457 let entry = js_string(entry);
458 return Some(format!(
459 "Invalid {name} setting: {entry}. Expected an object."
460 ));
461 }
462 if let Some(value) = entry.get(field).filter(invalid) {
463 let value = js_string(value);
464 return Some(format!(
465 "Invalid {name}.{field} setting: {value}. {expected}"
466 ));
467 }
468 }
469 None
470 }
471
472 pub fn cache_warming_mode(&self) -> CacheWarming {
476 self.global
477 .get("cacheWarming")
478 .and_then(|mode| CacheWarming::deserialize(mode).ok())
479 .unwrap_or(CacheWarming::Streaming)
480 }
481
482 pub fn project_dir(&self) -> &Path {
485 &self.project_dir
486 }
487
488 pub fn project_trusted(&self) -> bool {
490 self.project_trusted
491 }
492
493 pub fn document(&self, scope: Scope) -> &Map<String, Value> {
496 match scope {
497 Scope::Global => &self.global,
498 Scope::Project => &self.project,
499 }
500 }
501
502 pub fn set(
504 &mut self,
505 scope: Scope,
506 key: &str,
507 value: Option<Value>,
508 ) -> Result<(), SettingsError> {
509 self.update(scope, |document| match &value {
510 Some(value) => {
511 document.insert(key.to_owned(), value.clone());
512 }
513 None => {
514 document.shift_remove(key);
515 }
516 })
517 }
518
519 pub fn set_nested(
523 &mut self,
524 scope: Scope,
525 field: &str,
526 key: &str,
527 value: Option<Value>,
528 ) -> Result<(), SettingsError> {
529 self.update(scope, |document| {
530 let mut object = match document.get(field) {
531 Some(Value::Object(object)) => object.clone(),
532 _ => Map::new(),
533 };
534 match &value {
535 Some(value) => object.insert(key.to_owned(), value.clone()),
536 None => object.shift_remove(key),
537 };
538 document.insert(field.to_owned(), Value::Object(object));
539 })
540 }
541
542 fn update(
546 &mut self,
547 scope: Scope,
548 change: impl Fn(&mut Map<String, Value>),
549 ) -> Result<(), SettingsError> {
550 let (document, path, broken) = match scope {
551 Scope::Global => (&mut self.global, &self.global_path, self.broken[0]),
552 Scope::Project => (&mut self.project, &self.project_path, self.broken[1]),
553 };
554 let mut written = None;
555 if !path.as_os_str().is_empty() && !broken {
556 if let Some(dir) = path.parent() {
557 std::fs::create_dir_all(dir).map_err(|source| SettingsError::Io {
558 path: dir.to_path_buf(),
559 source,
560 })?;
561 }
562 let failed = |source: std::io::Error| SettingsError::Io {
563 path: path.clone(),
564 source,
565 };
566 let _lock = FileLock::acquire_blocking(path)
567 .map_err(|err| failed(std::io::Error::other(err)))?;
568 let mut current = read_document(path)?;
569 change(&mut current);
570 let text = ConfigFile::Settings
571 .render(&Value::Object(current.clone()))
572 .map_err(|err| SettingsError::Parse {
573 path: path.clone(),
574 message: err.to_string(),
575 })?;
576 write_atomic(path, text.as_bytes(), 0o666).map_err(failed)?;
577 written = Some(current);
578 }
579 match written {
580 Some(current) if scope == Scope::Global || self.project_trusted => *document = current,
582 _ => change(document),
583 }
584 self.remerge();
585 Ok(())
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592 use serde_json::json;
593
594 #[test]
595 fn merges_like_pi() {
596 let global = json!({"compaction": {"enabled": true, "reserveTokens": 100}, "defaultTools": ["read", "bash"], "theme": "dark"});
597 let project =
598 json!({"compaction": {"reserveTokens": 5}, "defaultTools": ["+grep", "-bash"]});
599 let mut merged = deep_merge(global.as_object().unwrap(), project.as_object().unwrap());
600 merged.insert(
601 "defaultTools".into(),
602 merge_default_tools(global.get("defaultTools"), project.get("defaultTools")).unwrap(),
603 );
604 assert_eq!(
605 Value::Object(merged),
606 json!({"compaction": {"enabled": true, "reserveTokens": 5}, "defaultTools": ["read", "bash", "+grep", "-bash"], "theme": "dark"})
607 );
608 assert_eq!(
609 merge_default_tools(Some(&json!(["read"])), Some(&json!(["ls"]))),
610 Some(json!(["ls"]))
611 );
612 let resolved = |global: &Value, project: &Value| {
614 let mut manager = SettingsManager::in_memory();
615 manager.global = Map::from_iter([("defaultTools".to_owned(), global.clone())]);
616 manager.project = Map::from_iter([("defaultTools".to_owned(), project.clone())]);
617 manager.default_tools().unwrap()
618 };
619 let (global, project) = (&global["defaultTools"], &project["defaultTools"]);
620 assert_eq!(resolved(global, project), ["read", "grep"]);
621 let plain = json!(["read", "+ls"]);
622 assert_eq!(resolved(&plain, &json!(["+find"])), ["read", "ls", "find"]);
623 let modifiers = json!(["-bash"]);
624 assert_eq!(
625 resolved(&modifiers, &json!(["+grep"])),
626 ["read", "edit", "write", "grep"]
627 );
628 assert!(resolved(&json!([]), &json!([])).is_empty());
630 assert!(resolved(&plain, &json!("read")).is_empty());
631 assert_eq!(SettingsManager::in_memory().default_tools(), None);
632 }
633
634 #[test]
636 fn migrates_like_pi() {
637 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
638 .join("../../tests/fixtures/pi/settings/migrations.json");
639 let cases: Vec<Value> =
640 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
641 let dir =
642 std::env::temp_dir().join(format!("yapi-settings-migrate-{}", std::process::id()));
643 std::fs::create_dir_all(&dir).unwrap();
644 let file = dir.join("settings.json");
645 for case in cases {
646 std::fs::write(&file, case["input"].as_str().unwrap()).unwrap();
647 let mut manager = SettingsManager::load(&dir, &dir.join("cwd"), false);
648 manager
649 .set(Scope::Global, "lastChangelogVersion", Some(json!("1.0.0")))
650 .unwrap();
651 assert_eq!(
652 std::fs::read_to_string(&file).unwrap(),
653 case["output"].as_str().unwrap(),
654 "{}",
655 case["name"]
656 );
657 }
658 std::fs::remove_dir_all(&dir).unwrap();
659 }
660
661 #[tokio::test]
662 async fn invalid_timeouts_fail() {
663 let mut manager = SettingsManager::in_memory();
664 manager.global =
665 json!({"httpIdleTimeoutMs": " 1500.7 ", "websocketConnectTimeoutMs": "soon"})
666 .as_object()
667 .unwrap()
668 .clone();
669 assert_eq!(manager.http_idle_timeout_ms(), Ok(1500));
670 assert_eq!(
671 manager.websocket_connect_timeout_ms(),
672 Err("Invalid websocketConnectTimeoutMs setting: soon".to_owned())
673 );
674 manager.global = json!({"httpIdleTimeoutMs": -1, "websocketConnectTimeoutMs": "disabled"})
675 .as_object()
676 .unwrap()
677 .clone();
678 assert_eq!(
679 manager.http_idle_timeout_ms(),
680 Err("Invalid httpIdleTimeoutMs setting: -1".to_owned())
681 );
682 assert_eq!(manager.websocket_connect_timeout_ms(), Ok(Some(0)));
683 manager.global = Map::new();
684 assert_eq!(manager.http_idle_timeout_ms(), Ok(300_000));
685 assert_eq!(manager.websocket_connect_timeout_ms(), Ok(None));
686 assert_eq!(manager.compaction_error(None), None);
687 manager.global = json!({"compaction": {"reserveTokens": 1.5e5, "keepRecentTokens": 1.5}})
689 .as_object()
690 .unwrap()
691 .clone();
692 let error = "Invalid compaction.keepRecentTokens setting: 1.5. Expected a non-negative safe integer.";
693 assert_eq!(manager.compaction_error(None).as_deref(), Some(error));
694
695 use yapi_ai::faux::{Faux, Response};
699 use yapi_types::message::{Message, StopReason};
700 let faux = Faux::new([
701 Response::tool_call("t", "missing", json!({})),
702 Response::text("unused"),
703 ]);
704 let model: yapi_types::model::Model = serde_json::from_value(json!({
705 "id": "m", "name": "M", "api": "faux", "provider": "p", "baseUrl": "",
706 "reasoning": false, "input": ["text"],
707 "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
708 "contextWindow": 1000, "maxTokens": 100,
709 }))
710 .unwrap();
711 let mut registry = yapi_ai::registry::ModelRegistry::builtin();
712 registry.register_provider("p", vec![model.clone()]);
713 registry.set_runtime_key("p", "key".into());
714 let mut apis = yapi_ai::api::Apis::default();
715 apis.register(std::sync::Arc::new(faux.clone()));
716 let session =
717 crate::agent_session::AgentSession::new(crate::agent_session::SessionConfig {
718 cwd: "/work".into(),
719 agent_dir: "/agent".into(),
720 settings: manager,
721 registry,
722 apis,
723 session: crate::session::SessionManager::in_memory(Path::new("/work")),
724 model: Some(model),
725 thinking_level: yapi_types::message::ThinkingLevel::Off,
726 tools: Vec::new(),
727 extensions: Vec::new(),
728 include_extension_tools: false,
729 allowed_tools: None,
730 excluded_tools: Vec::new(),
731 resources: crate::agent_session::Resources::default(),
732 docs: crate::docs::Locations::default(),
733 });
734 assert_eq!(session.prompt("go", Vec::new()).await, Err(error.into()));
735 let Some(Message::Assistant(failed)) = session.messages().pop() else {
736 panic!("no failed response");
737 };
738 assert_eq!(
739 (failed.stop_reason, failed.error_message.as_deref()),
740 (StopReason::Error, Some(error))
741 );
742 assert_eq!(session.prompt("again", Vec::new()).await, Err(error.into()));
743 assert_eq!(faux.requests().len(), 1);
744 }
745
746 #[test]
747 fn invalid_fields_keep_valid() {
748 let mut manager = SettingsManager::in_memory();
749 manager.global = json!({
750 "theme": "dark",
751 "retry": "often",
752 "steeringMode": "all",
753 "markdown": {"codeBlockIndent": " ", "mermaid": "bogus"},
754 "compaction": {"enabled": "", "reserveTokens": "16384", "keepRecentTokens": 2e4},
755 "branchSummary": {"skipPrompt": true, "reserveTokens": 1.5e5},
756 "modelThinkingLevels": {"a/b": "high", "a/c": "extreme"},
757 "terminal": {"imageWidthCells": 40.7},
758 "outputPad": 0.5,
759 })
760 .as_object()
761 .unwrap()
762 .clone();
763 manager.remerge();
764 let settings = manager.settings();
765 assert_eq!(settings.theme.as_deref(), Some("dark"));
766 assert!(settings.retry.is_none());
767 let compaction = settings.compaction.as_ref().unwrap();
770 let tokens = (compaction.reserve_tokens, compaction.keep_recent_tokens);
771 assert_eq!(
772 (compaction.enabled, tokens),
773 (Some(false), (None, Some(20000)))
774 );
775 let summary = settings.branch_summary.as_ref().unwrap();
778 assert_eq!(
779 (summary.skip_prompt, summary.reserve_tokens),
780 (Some(true), Some(150_000))
781 );
782 let terminal = settings.terminal.as_ref().unwrap();
783 assert_eq!(
784 (terminal.image_width_cells, settings.output_pad),
785 (Some(40), None)
786 );
787 assert_eq!(settings.model_thinking_levels.as_ref().unwrap().len(), 1);
788 assert_eq!(
789 settings.steering_mode,
790 Some(yapi_types::settings::QueueMode::All)
791 );
792 assert_eq!(
794 settings.markdown,
795 Some(yapi_types::settings::MarkdownSettings {
796 code_block_indent: Some(" ".into()),
797 mermaid: Some(yapi_types::settings::MermaidMode::Streaming),
798 })
799 );
800 }
801
802 #[test]
803 fn cache_warming_global_only() {
804 let mut manager = SettingsManager::in_memory();
805 assert_eq!(manager.cache_warming_mode(), CacheWarming::Streaming);
806 manager.project = json!({"cacheWarming": "off"}).as_object().unwrap().clone();
807 manager.remerge();
808 assert_eq!(manager.cache_warming_mode(), CacheWarming::Streaming);
809 manager.global = json!({"cacheWarming": "idle"}).as_object().unwrap().clone();
810 assert_eq!(manager.cache_warming_mode(), CacheWarming::Idle);
811 manager.global = json!({"cacheWarming": "often"})
812 .as_object()
813 .unwrap()
814 .clone();
815 assert_eq!(manager.cache_warming_mode(), CacheWarming::Streaming);
816 }
817
818 #[test]
820 fn concurrent_writes_keep_keys() {
821 let dir = std::env::temp_dir().join(format!("yapi-settings-two-{}", std::process::id()));
822 let _ = std::fs::remove_dir_all(&dir);
823 std::fs::create_dir_all(&dir).unwrap();
824 let mut first = SettingsManager::load(&dir, &dir.join("cwd"), false);
825 let mut second = SettingsManager::load(&dir, &dir.join("cwd"), false);
826 second
827 .set(Scope::Global, "packages", Some(json!(["npm:foo"])))
828 .unwrap();
829 first
830 .set(Scope::Global, "defaultModel", Some(json!("m")))
831 .unwrap();
832 first
833 .set_nested(Scope::Global, "retry", "enabled", Some(json!(false)))
834 .unwrap();
835 assert_eq!(
836 std::fs::read_to_string(dir.join("settings.json")).unwrap(),
837 "{\n \"packages\": [\n \"npm:foo\"\n ],\n \"defaultModel\": \"m\",\n \"retry\": {\n \"enabled\": false\n }\n}"
838 );
839 assert_eq!(first.document(Scope::Global).len(), 3);
840 assert!(!dir.join("settings.json.lock").exists());
841 std::fs::remove_dir_all(&dir).unwrap();
842 }
843}