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
28pub const TOML_FILE: &str = "settings.toml";
31
32#[derive(Debug, thiserror::Error)]
34pub enum SettingsError {
35 #[error("{path}: {source}")]
37 Io {
38 path: PathBuf,
40 source: std::io::Error,
42 },
43 #[error("Failed to parse {path}: {message}")]
45 Parse {
46 path: PathBuf,
48 message: String,
50 },
51 #[error("Both settings.json and settings.toml are in {}. Keep one of them.", dir.display())]
53 Conflict {
54 dir: PathBuf,
56 },
57}
58
59pub fn settings_file(dir: &Path) -> Result<PathBuf, SettingsError> {
63 let json = dir.join(ConfigFile::Settings.file_name());
64 let toml = dir.join(TOML_FILE);
65 match (json.exists(), toml.exists()) {
66 (true, true) => Err(SettingsError::Conflict {
67 dir: dir.to_path_buf(),
68 }),
69 (_, true) => Ok(toml),
70 _ => Ok(json),
71 }
72}
73
74pub fn read_settings(path: &Path) -> Result<Map<String, Value>, SettingsError> {
77 read_document(path)
78}
79
80fn is_toml(path: &Path) -> bool {
81 path.extension()
82 .is_some_and(|extension| extension == "toml")
83}
84
85fn read_toml_document(path: &Path) -> Result<(toml_edit::DocumentMut, String), SettingsError> {
88 let text = match std::fs::read_to_string(path) {
89 Ok(text) => text,
90 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
91 return Ok((toml_edit::DocumentMut::new(), String::new()));
92 }
93 Err(source) => {
94 return Err(SettingsError::Io {
95 path: path.to_path_buf(),
96 source,
97 });
98 }
99 };
100 let document = text
101 .strip_prefix('\u{feff}')
102 .unwrap_or(&text)
103 .parse()
104 .map_err(|err: toml_edit::TomlError| SettingsError::Parse {
105 path: path.to_path_buf(),
106 message: err.to_string(),
107 })?;
108 Ok((document, text))
109}
110
111fn as_read(text: &str, written: String) -> String {
116 let (bom, text) = match text.strip_prefix('\u{feff}') {
117 Some(text) => ("\u{feff}", text),
118 None => ("", text),
119 };
120 let written = match text.starts_with(['\r', '\n']) {
121 true => written,
122 false => written.trim_start_matches(['\r', '\n']).to_owned(),
123 };
124 let written = match text.contains("\r\n") {
125 true => written.replace("\r\n", "\n").replace('\n', "\r\n"),
126 false => written,
127 };
128 format!("{bom}{written}")
129}
130
131fn toml_settings(
135 path: &Path,
136 document: &toml_edit::DocumentMut,
137) -> Result<Map<String, Value>, SettingsError> {
138 let mut map = toml_table(document.as_table()).map_err(|message| SettingsError::Parse {
139 path: path.to_path_buf(),
140 message,
141 })?;
142 migrate(&mut map);
143 Ok(map)
144}
145
146fn toml_table(table: &dyn toml_edit::TableLike) -> Result<Map<String, Value>, String> {
147 let mut map = Map::new();
148 for (key, item) in table.iter() {
149 if let Some(value) = toml_item(item)? {
150 map.insert(key.to_owned(), value);
151 }
152 }
153 Ok(map)
154}
155
156fn toml_item(item: &toml_edit::Item) -> Result<Option<Value>, String> {
157 Ok(match item {
158 toml_edit::Item::None => None,
159 toml_edit::Item::Value(value) => Some(toml_value(value)?),
160 toml_edit::Item::Table(table) => Some(Value::Object(toml_table(table)?)),
161 toml_edit::Item::ArrayOfTables(tables) => Some(Value::Array(
162 tables
163 .iter()
164 .map(|table| toml_table(table).map(Value::Object))
165 .collect::<Result<_, _>>()?,
166 )),
167 })
168}
169
170fn toml_value(value: &toml_edit::Value) -> Result<Value, String> {
171 use toml_edit::Value as Toml;
172 Ok(match value {
173 Toml::String(text) => Value::String(text.value().clone()),
174 Toml::Integer(number) => Value::from(*number.value()),
175 Toml::Float(number) => serde_json::Number::from_f64(*number.value())
176 .map(Value::Number)
177 .ok_or_else(|| format!("{} is not a number JSON can hold", number.value()))?,
178 Toml::Boolean(flag) => Value::Bool(*flag.value()),
179 Toml::Datetime(date) => {
180 return Err(format!(
181 "{} is a date or time, which no setting takes",
182 date.value()
183 ));
184 }
185 Toml::Array(items) => Value::Array(items.iter().map(toml_value).collect::<Result<_, _>>()?),
186 Toml::InlineTable(table) => Value::Object(toml_table(table)?),
187 })
188}
189
190fn json_to_toml(value: &Value) -> Result<toml_edit::Value, String> {
192 Ok(match value {
193 Value::Null => return Err("TOML has no null in an array".to_owned()),
194 Value::Bool(flag) => (*flag).into(),
195 Value::Number(number) => match (number.as_i64(), number.as_f64()) {
196 (Some(integer), _) => integer.into(),
197 (None, Some(float)) if !number.is_u64() => float.into(),
198 _ => return Err(format!("{number} is beyond the integers TOML holds")),
199 },
200 Value::String(text) => text.as_str().into(),
201 Value::Array(items) => {
202 let mut array = toml_edit::Array::new();
203 for item in items {
204 array.push(json_to_toml(item)?);
205 }
206 array.into()
207 }
208 Value::Object(map) => {
209 let mut table = toml_edit::InlineTable::new();
210 for (key, value) in map.iter().filter(|(_, value)| !value.is_null()) {
211 table.insert(key, json_to_toml(value)?);
212 }
213 table.into()
214 }
215 })
216}
217
218fn reconcile(
224 table: &mut dyn toml_edit::TableLike,
225 new: &Map<String, Value>,
226 root: bool,
227 lines: bool,
228) -> Result<(), String> {
229 let gone: Vec<String> = table
230 .iter()
231 .map(|(key, _)| key.to_owned())
232 .filter(|key| new.get(key).is_none_or(Value::is_null))
233 .collect();
234 for key in gone {
235 table.remove(&key);
236 }
237 for (key, value) in new.iter().filter(|(_, value)| !value.is_null()) {
238 let line = table.key(key).filter(|_| lines).map(|key| {
242 let decor = key.leaf_decor();
243 let prefix = raw(decor.prefix());
244 let indent = &prefix[prefix.rfind('\n').map_or(0, |at| at + 1)..];
245 format!("{indent}{}{}=", key.display_repr(), raw(decor.suffix()))
246 });
247 let in_place = match table.get_mut(key) {
248 Some(item) => reconcile_item(item, value, line.as_deref())?,
249 None => false,
250 };
251 if !in_place {
253 let old = table.remove(key);
254 let mut item = match (root, json_to_toml(value)?) {
255 (true, toml_edit::Value::InlineTable(inline)) => {
256 toml_edit::Item::Table(inline.into_table())
257 }
258 (_, value) => toml_edit::Item::Value(value),
259 };
260 let mut above = None;
263 if let (
264 Some(toml_edit::Item::ArrayOfTables(tables)),
265 toml_edit::Item::Value(toml_edit::Value::Array(array)),
266 Value::Array(items),
267 ) = (&old, &mut item, value)
268 {
269 above = tables
270 .get(0)
271 .map(|first| raw(first.decor().prefix()).to_owned());
272 *array = tables_to_array(tables, items)?;
273 }
274 table.insert(key, item);
275 if let (Some(above), Some(mut key)) = (above, table.key_mut(key)) {
276 key.leaf_decor_mut().set_prefix(above);
277 }
278 }
279 }
280 Ok(())
281}
282
283fn raw(text: Option<&toml_edit::RawString>) -> &str {
285 text.and_then(toml_edit::RawString::as_str)
286 .unwrap_or_default()
287}
288
289fn split_line(text: &str) -> (&str, &str) {
292 text.find('\n')
293 .map_or(("", text), |at| text.split_at(at + 1))
294}
295
296fn pick(old: &[Option<Value>], new: &[Value]) -> Vec<Option<usize>> {
300 let mut kept = vec![false; old.len()];
301 let mut picks: Vec<Option<usize>> = new
302 .iter()
303 .map(|entry| {
304 let index = (0..old.len())
305 .find(|index| !kept[*index] && old[*index].as_ref() == Some(entry))?;
306 kept[index] = true;
307 Some(index)
308 })
309 .collect();
310 for index in 0..new.len().min(old.len()) {
311 if picks[index].is_none() && !kept[index] {
312 picks[index] = Some(index);
313 }
314 }
315 picks
316}
317
318fn reconcile_array(
324 array: &mut toml_edit::Array,
325 items: &[Value],
326 line: Option<&str>,
327) -> Result<(), String> {
328 let old: Vec<toml_edit::Value> = array.iter().cloned().collect();
329 let last = old.len().saturating_sub(1);
330 let mut suffixes: Vec<String> = old
331 .iter()
332 .map(|value| raw(value.decor().suffix()).to_owned())
333 .collect();
334 let mut after = raw(Some(array.trailing())).to_owned();
336 if !array.trailing_comma() {
337 after = std::mem::take(&mut suffixes[last]) + &after;
338 }
339 let prefixes: Vec<&str> = old
342 .iter()
343 .map(|value| raw(value.decor().prefix()))
344 .collect();
345 let (head, first) = split_line(prefixes[0]);
346 let mut leads = vec![first];
347 let mut tails = Vec::new();
348 for prefix in &prefixes[1..] {
349 let (tail, lead) = split_line(prefix);
350 tails.push(tail);
351 leads.push(lead);
352 }
353 let (tail, close) = split_line(&after);
354 tails.push(tail);
355 let comments = |lead: &str| lead.rfind('\n').map_or("", |at| &lead[..=at]).to_owned();
356 let multi_line = prefixes.iter().any(|prefix| prefix.contains('\n')) || after.contains('\n');
360 let new_tail = if multi_line { "\n" } else { "" };
361 let column: Option<String> = line.map(|line| {
363 let text = format!("{line}[{}", prefixes[0]);
364 text.chars()
365 .map(|c| if c == '\t' { c } else { ' ' })
366 .collect()
367 });
368 let indent = |index: usize| match index > 0 && last == 0 && !prefixes[0].contains('\n') {
369 true => match (multi_line, &column) {
372 (true, Some(column)) => column.clone(),
373 _ => " ".to_owned(),
374 },
375 false => {
376 let lead = leads[index.min(last)];
377 let at = lead.rfind('\n').map_or(0, |at| at + 1);
378 lead[at..].to_owned()
379 }
380 };
381 let end = if tails[last].contains('\n') { "\n" } else { "" };
382 let values: Vec<Option<Value>> = old.iter().map(|value| toml_value(value).ok()).collect();
383 let picks = pick(&values, items);
384 let new_last = matches!(picks.last(), Some(None));
385 array.clear();
386 let mut before = head.to_owned();
387 let mut previous = None;
388 for (index, (item, picked)) in items.iter().zip(picks).enumerate() {
389 let mut value = match picked {
390 Some(at) if values[at].as_ref() == Some(item) => old[at].clone(),
391 _ => json_to_toml(item)?,
392 };
393 let starts_line = picked.is_none() || previous == Some(last);
394 if index > 0 && starts_line && !before.ends_with('\n') {
395 before += new_tail;
396 }
397 previous = picked;
398 let lead = picked.map_or(String::new(), |at| comments(leads[at]));
399 let decor = value.decor_mut();
400 decor.set_prefix(before + &lead + &indent(index));
401 decor.set_suffix(picked.map_or("", |at| suffixes[at].as_str()));
402 array.push_formatted(value);
403 before = picked.map_or(new_tail, |at| tails[at]).to_owned();
404 }
405 let trailing = match (items.is_empty(), new_last) {
406 (true, _) => String::new(),
407 (false, true) => end.to_owned() + close,
408 (false, false) => before + close,
409 };
410 array.set_trailing(trailing);
411 Ok(())
412}
413
414fn tables_to_array(
420 tables: &toml_edit::ArrayOfTables,
421 items: &[Value],
422) -> Result<toml_edit::Array, String> {
423 let values: Vec<Option<Value>> = tables
424 .iter()
425 .map(|table| toml_table(table).ok().map(Value::Object))
426 .collect();
427 let mut array = toml_edit::Array::new();
428 for (item, picked) in items.iter().zip(pick(&values, items)) {
429 let mut texts = Vec::new();
430 if let Some(at) = picked
431 && let Some(table) = tables.get(at)
432 {
433 if at > 0 {
434 texts.push(raw(table.decor().prefix()));
435 }
436 texts.push(raw(table.decor().suffix()));
437 for (keys, value) in table.get_values() {
438 let key = keys.last().and_then(|key| key.leaf_decor().prefix());
439 texts.push(raw(key));
440 nested_comments(value, &mut texts);
441 texts.push(raw(value.decor().suffix()));
442 }
443 }
444 let comments: String = texts
445 .iter()
446 .flat_map(|text| text.lines())
447 .map(str::trim)
448 .filter(|line| !line.is_empty())
449 .map(|line| format!("\n {line}"))
450 .collect();
451 let mut value = json_to_toml(item)?;
452 value.decor_mut().set_prefix(format!("{comments}\n "));
453 value.decor_mut().set_suffix("");
454 array.push_formatted(value);
455 }
456 if !array.is_empty() {
457 array.set_trailing_comma(true);
458 array.set_trailing("\n");
459 }
460 Ok(array)
461}
462
463fn nested_comments<'a>(value: &'a toml_edit::Value, texts: &mut Vec<&'a str>) {
466 if let toml_edit::Value::Array(array) = value {
467 for item in array.iter() {
468 texts.push(raw(item.decor().prefix()));
469 nested_comments(item, texts);
470 texts.push(raw(item.decor().suffix()));
471 }
472 texts.push(raw(Some(array.trailing())));
473 }
474}
475
476fn reconcile_item(
480 item: &mut toml_edit::Item,
481 value: &Value,
482 line: Option<&str>,
483) -> Result<bool, String> {
484 if toml_item(item).ok().flatten().as_ref() == Some(value) {
485 return Ok(true);
486 }
487 match (&mut *item, value) {
488 (toml_edit::Item::Table(table), Value::Object(map)) => {
489 let lines = !table.is_dotted();
490 reconcile(table, map, false, lines)?;
491 return Ok(true);
492 }
493 (toml_edit::Item::Value(toml_edit::Value::InlineTable(table)), Value::Object(map)) => {
494 reconcile(table, map, false, false)?;
495 return Ok(true);
496 }
497 (toml_edit::Item::ArrayOfTables(tables), Value::Array(items))
501 if !items.is_empty() && items.iter().all(Value::is_object) =>
502 {
503 let old: Vec<toml_edit::Table> = tables.iter().cloned().collect();
504 let values: Vec<Option<Value>> = old
505 .iter()
506 .map(|table| toml_table(table).ok().map(Value::Object))
507 .collect();
508 let picks = pick(&values, items);
509 let mut positions: Vec<Option<isize>> = picks
511 .iter()
512 .flatten()
513 .map(|at| old[*at].position())
514 .collect();
515 positions.sort();
516 let mut positions = positions.into_iter();
517 let mut next = toml_edit::ArrayOfTables::new();
518 for (entry, picked) in items.iter().zip(picks) {
519 let Value::Object(map) = entry else { continue };
520 let mut table = picked.map_or_else(toml_edit::Table::new, |at| old[at].clone());
521 if picked.is_some() {
522 table.set_position(positions.next().flatten());
523 }
524 reconcile(&mut table, map, false, true)?;
525 next.push(table);
526 }
527 *tables = next;
528 return Ok(true);
529 }
530 (toml_edit::Item::Value(toml_edit::Value::Array(array)), Value::Array(items))
531 if !array.is_empty() =>
532 {
533 let line = line.map(|line| format!("{line}{}", raw(array.decor().prefix())));
534 reconcile_array(array, items, line.as_deref())?;
535 return Ok(true);
536 }
537 (toml_edit::Item::Value(old), _) => {
538 let mut next = json_to_toml(value)?;
540 *next.decor_mut() = old.decor().clone();
541 *old = next;
542 return Ok(true);
543 }
544 _ => {}
545 }
546 Ok(false)
547}
548
549#[derive(Clone, Debug, Default)]
551pub struct SettingsManager {
552 project_dir: PathBuf,
553 global_path: PathBuf,
554 project_path: PathBuf,
555 global: Map<String, Value>,
556 project: Map<String, Value>,
557 project_trusted: bool,
558 document: Map<String, Value>,
560 merged: Settings,
561 broken: [bool; 2],
564 errors: Vec<String>,
565 conflict: Option<String>,
567}
568
569fn read_document(path: &Path) -> Result<Map<String, Value>, SettingsError> {
570 if is_toml(path) {
571 return toml_settings(path, &read_toml_document(path)?.0);
572 }
573 let text = match std::fs::read_to_string(path) {
574 Ok(text) => text,
575 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Map::new()),
576 Err(source) => {
577 return Err(SettingsError::Io {
578 path: path.to_path_buf(),
579 source,
580 });
581 }
582 };
583 let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
584 if text.trim().is_empty() {
585 return Ok(Map::new());
586 }
587 match serde_json::from_str::<Value>(text) {
588 Ok(Value::Object(mut map)) => {
589 migrate(&mut map);
590 Ok(map)
591 }
592 Ok(_) => Err(SettingsError::Parse {
593 path: path.to_path_buf(),
594 message: "expected an object".into(),
595 }),
596 Err(err) => Err(SettingsError::Parse {
597 path: path.to_path_buf(),
598 message: err.to_string(),
599 }),
600 }
601}
602
603fn migrate(document: &mut Map<String, Value>) {
608 if !document.contains_key("steeringMode")
609 && let Some(mode) = document.shift_remove("queueMode")
610 {
611 document.insert("steeringMode".into(), mode);
612 }
613 if let Some(&Value::Bool(websockets)) = document.get("websockets")
614 && !document.contains_key("transport")
615 {
616 let transport = if websockets { "websocket" } else { "sse" };
617 document.insert("transport".into(), Value::from(transport));
618 document.shift_remove("websockets");
619 }
620 if let Some(Value::Object(skills)) = document.get("skills").cloned() {
621 if let Some(enable) = skills.get("enableSkillCommands")
622 && !document.contains_key("enableSkillCommands")
623 {
624 document.insert("enableSkillCommands".into(), enable.clone());
625 }
626 match skills.get("customDirectories") {
627 Some(Value::Array(directories)) if !directories.is_empty() => {
628 document.insert("skills".into(), Value::Array(directories.clone()));
629 }
630 _ => {
631 document.shift_remove("skills");
632 }
633 }
634 }
635 if let Some(Value::Object(retry)) = document.get_mut("retry") {
636 let provider = retry.get("provider").and_then(Value::as_object).cloned();
637 let delay_unset = provider
638 .as_ref()
639 .is_none_or(|provider| provider.get("maxRetryDelayMs").is_none_or(Value::is_null));
640 if let Some(delay) = retry.get("maxDelayMs").filter(|delay| delay.is_number())
641 && delay_unset
642 {
643 let mut provider = provider.unwrap_or_default();
644 provider.insert("maxRetryDelayMs".into(), delay.clone());
645 retry.insert("provider".into(), Value::Object(provider));
646 }
647 retry.shift_remove("maxDelayMs");
648 }
649}
650
651pub fn js_string(value: &Value) -> String {
653 match value {
654 Value::String(text) => text.clone(),
655 Value::Array(items) => items
656 .iter()
657 .map(|item| match item {
658 Value::Null => String::new(),
659 item => js_string(item),
660 })
661 .collect::<Vec<_>>()
662 .join(","),
663 Value::Object(_) => "[object Object]".to_owned(),
664 other => yapi_types::json::stringify(other),
665 }
666}
667
668fn deep_merge(base: &Map<String, Value>, overrides: &Map<String, Value>) -> Map<String, Value> {
670 let mut result = base.clone();
671 for (key, value) in overrides {
672 let merged = match (base.get(key), value) {
673 (Some(Value::Object(base)), Value::Object(over)) => {
674 Value::Object(deep_merge(base, over))
675 }
676 _ => value.clone(),
677 };
678 result.insert(key.clone(), merged);
679 }
680 result
681}
682
683fn is_tool_modifier(entry: &str) -> bool {
684 entry.starts_with('+') || entry.starts_with('-')
685}
686
687fn merge_default_tools(base: Option<&Value>, overrides: Option<&Value>) -> Option<Value> {
690 let Some(overrides) = overrides else {
691 return base.cloned();
692 };
693 match (base.and_then(Value::as_array), overrides.as_array()) {
694 (Some(base), Some(list))
695 if list
696 .iter()
697 .all(|entry| entry.as_str().is_some_and(is_tool_modifier)) =>
698 {
699 Some(Value::Array(base.iter().chain(list).cloned().collect()))
700 }
701 _ => Some(overrides.clone()),
702 }
703}
704
705fn resolve_default_tools(entries: &[&str]) -> Vec<String> {
709 let mut tools: Vec<String> = entries
710 .iter()
711 .filter(|entry| !is_tool_modifier(entry))
712 .map(|&entry| entry.to_owned())
713 .collect();
714 if tools.is_empty() && !entries.is_empty() {
715 tools = crate::tools::DEFAULT_TOOLS.map(str::to_owned).to_vec();
716 }
717 for entry in entries.iter().filter(|entry| is_tool_modifier(entry)) {
718 let name = &entry[1..];
719 let index = tools.iter().position(|tool| tool == name);
720 match index {
721 None if entry.starts_with('+') && !name.is_empty() => tools.push(name.to_owned()),
722 Some(index) if entry.starts_with('-') => {
723 tools.remove(index);
724 }
725 _ => {}
726 }
727 }
728 tools
729}
730
731fn reads_at(path: &[String], value: &Value) -> bool {
733 let nested = path.iter().rev().fold(value.clone(), |inner, key| {
734 Value::Object(Map::from_iter([(key.clone(), inner)]))
735 });
736 Settings::deserialize(nested).is_ok()
737}
738
739fn valid_fields(path: &mut Vec<String>, object: Map<String, Value>) -> Map<String, Value> {
743 let mut valid = Map::new();
744 for (key, value) in object {
745 path.push(key.clone());
746 let kept = match value {
747 value if reads_at(path, &value) => Some(value),
748 Value::Object(inner) => {
749 Some(Value::Object(valid_fields(path, inner))).filter(|inner| reads_at(path, inner))
750 }
751 _ => None,
752 };
753 path.pop();
754 if let Some(value) = kept {
755 valid.insert(key, value);
756 }
757 }
758 valid
759}
760
761pub fn http_proxy(agent_dir: &Path) -> Option<String> {
764 let path = settings_file(agent_dir).ok()?;
765 read_document(&path)
766 .ok()?
767 .get("httpProxy")?
768 .as_str()
769 .map(str::to_owned)
770}
771
772pub fn apply_http_proxy() {
775 let proxy = http_proxy(&crate::config::agent_dir());
776 yapi_ai::http::set_settings_proxy(proxy.as_deref());
777}
778
779impl SettingsManager {
780 pub fn load(agent_dir: &Path, project_dir: &Path, project_trusted: bool) -> SettingsManager {
785 let project_folder = project_dir.join(PROJECT_DIR);
786 let mut conflict = None;
787 let mut broken = [false; 2];
788 let mut locate = |dir: &Path, scope: usize, read: bool| {
790 let json = dir.join(ConfigFile::Settings.file_name());
791 match read.then(|| settings_file(dir)) {
792 Some(Ok(path)) => path,
793 Some(Err(err)) => {
794 conflict.get_or_insert(err.to_string());
795 broken[scope] = true;
796 json
797 }
798 None => json,
799 }
800 };
801 let global_path = locate(agent_dir, 0, true);
802 let project_path = locate(&project_folder, 1, project_trusted);
803 let mut errors = Vec::new();
804 let mut read = |path: &Path, scope: usize| match read_document(path) {
806 _ if broken[scope] => Map::new(),
807 Ok(document) => document,
808 Err(error) => {
809 let message = match error {
810 SettingsError::Parse { message, .. } => message,
811 SettingsError::Io { source, .. } => source.to_string(),
812 SettingsError::Conflict { .. } => error.to_string(),
813 };
814 errors.push(format!(
815 "Invalid settings file {}: {message}",
816 path.display()
817 ));
818 broken[scope] = true;
819 Map::new()
820 }
821 };
822 let global = read(&global_path, 0);
823 let project = if project_trusted {
824 read(&project_path, 1)
825 } else {
826 Map::new()
827 };
828 let mut manager = SettingsManager {
829 project_dir: project_dir.to_path_buf(),
830 global_path,
831 project_path,
832 global,
833 project,
834 project_trusted,
835 document: Map::new(),
836 merged: Settings::default(),
837 broken,
838 errors,
839 conflict,
840 };
841 manager.remerge();
842 manager
843 }
844
845 pub fn file(&self, scope: Scope) -> &Path {
848 match scope {
849 Scope::Global => &self.global_path,
850 Scope::Project => &self.project_path,
851 }
852 }
853
854 pub fn conflict(&self) -> Option<&str> {
857 self.conflict.as_deref()
858 }
859
860 pub fn in_memory() -> SettingsManager {
862 SettingsManager::default()
863 }
864
865 pub fn errors(&self) -> &[String] {
867 &self.errors
868 }
869
870 fn remerge(&mut self) {
871 let mut merged = deep_merge(&self.global, &self.project);
872 if let Some(tools) = merge_default_tools(
873 self.global.get("defaultTools"),
874 self.project.get("defaultTools"),
875 ) {
876 merged.insert("defaultTools".into(), tools);
877 }
878 self.document = merged.clone();
879 for field in ["compaction", "retry"] {
882 if let Some(Value::Object(object)) = merged.get_mut(field)
883 && let Some(enabled) = object.get("enabled").filter(|value| !value.is_boolean())
884 {
885 match enabled {
886 Value::Null => object.shift_remove("enabled"),
887 value => object.insert("enabled".into(), is_truthy(value).into()),
888 };
889 }
890 }
891 self.merged = Settings::deserialize(Value::Object(merged.clone())).unwrap_or_else(|_| {
893 let valid = Value::Object(valid_fields(&mut Vec::new(), merged));
894 Settings::deserialize(valid).unwrap_or_default()
895 });
896 }
897
898 pub fn settings(&self) -> &Settings {
900 &self.merged
901 }
902
903 pub fn default_tools(&self) -> Option<Vec<String>> {
907 let merged = merge_default_tools(
908 self.global.get("defaultTools"),
909 self.project.get("defaultTools"),
910 )?;
911 let entries: Vec<&str> = match &merged {
912 Value::Array(entries) => entries.iter().filter_map(Value::as_str).collect(),
913 _ => Vec::new(),
914 };
915 Some(resolve_default_tools(&entries))
916 }
917
918 pub fn merged_document(&self) -> &Map<String, Value> {
921 &self.document
922 }
923
924 pub fn default_thinking_level(&self) -> Option<yapi_types::message::ThinkingLevel> {
927 let value = self
928 .project
929 .get("defaultThinkingLevel")
930 .or_else(|| self.global.get("defaultThinkingLevel"))?;
931 Some(
932 value
933 .as_str()
934 .and_then(yapi_types::message::ThinkingLevel::parse)
935 .unwrap_or(yapi_types::message::ThinkingLevel::Off),
936 )
937 }
938
939 pub fn http_idle_timeout_ms(&self) -> Result<u64, String> {
942 Ok(self.timeout_ms("httpIdleTimeoutMs")?.unwrap_or(300_000))
943 }
944
945 pub fn websocket_connect_timeout_ms(&self) -> Result<Option<u64>, String> {
948 self.timeout_ms("websocketConnectTimeoutMs")
949 }
950
951 pub fn provider_timeout_ms(&self) -> Option<f64> {
954 let read = |document: &Map<String, Value>| {
955 document
956 .get("retry")?
957 .get("provider")?
958 .get("timeoutMs")?
959 .as_f64()
960 };
961 read(&self.project).or_else(|| read(&self.global))
962 }
963
964 fn timeout_ms(&self, key: &str) -> Result<Option<u64>, String> {
968 let Some(value) = self.project.get(key).or_else(|| self.global.get(key)) else {
969 return Ok(None);
970 };
971 let number = match value {
972 Value::String(text) if text.trim().eq_ignore_ascii_case("disabled") => Some(0.0),
973 Value::String(text) if text.trim().is_empty() => None,
974 Value::String(text) => text.trim().parse::<f64>().ok(),
975 Value::Number(number) => number.as_f64(),
976 _ => None,
977 };
978 number
979 .filter(|ms| ms.is_finite() && *ms >= 0.0)
980 .map(|ms| Some(ms.floor() as u64))
981 .ok_or_else(|| format!("Invalid {key} setting: {}", js_string(value)))
982 }
983
984 pub fn compaction_error(&self, model: Option<&yapi_types::model::Model>) -> Option<String> {
988 const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0;
989 let merged = deep_merge(&self.global, &self.project);
990 let compaction = merged.get("compaction");
991 let key = model.map(|model| format!("{}/{}", model.provider, model.id));
992 let entry = key
993 .as_ref()
994 .and_then(|key| compaction?.get("modelOverrides")?.get(key));
995 let invalid = |value: &&Value| {
996 !value
997 .as_f64()
998 .is_some_and(|n| n.fract() == 0.0 && (0.0..=MAX_SAFE_INTEGER).contains(&n))
999 };
1000 let expected = "Expected a non-negative safe integer.";
1001 for field in ["reserveTokens", "keepRecentTokens"] {
1002 if let Some(value) = compaction.and_then(|c| c.get(field)).filter(invalid) {
1003 let value = js_string(value);
1004 return Some(format!(
1005 "Invalid compaction.{field} setting: {value}. {expected}"
1006 ));
1007 }
1008 let (Some(key), Some(entry)) = (&key, entry) else {
1009 continue;
1010 };
1011 let name = format!("compaction.modelOverrides[\"{key}\"]");
1012 if !entry.is_object() {
1013 let entry = js_string(entry);
1014 return Some(format!(
1015 "Invalid {name} setting: {entry}. Expected an object."
1016 ));
1017 }
1018 if let Some(value) = entry.get(field).filter(invalid) {
1019 let value = js_string(value);
1020 return Some(format!(
1021 "Invalid {name}.{field} setting: {value}. {expected}"
1022 ));
1023 }
1024 }
1025 None
1026 }
1027
1028 pub fn cache_warming_mode(&self) -> CacheWarming {
1032 self.global
1033 .get("cacheWarming")
1034 .and_then(|mode| CacheWarming::deserialize(mode).ok())
1035 .unwrap_or(CacheWarming::Streaming)
1036 }
1037
1038 pub fn project_dir(&self) -> &Path {
1041 &self.project_dir
1042 }
1043
1044 pub fn project_trusted(&self) -> bool {
1046 self.project_trusted
1047 }
1048
1049 pub fn document(&self, scope: Scope) -> &Map<String, Value> {
1052 match scope {
1053 Scope::Global => &self.global,
1054 Scope::Project => &self.project,
1055 }
1056 }
1057
1058 pub fn set(
1060 &mut self,
1061 scope: Scope,
1062 key: &str,
1063 value: Option<Value>,
1064 ) -> Result<(), SettingsError> {
1065 self.update(scope, |document| match &value {
1066 Some(value) => {
1067 document.insert(key.to_owned(), value.clone());
1068 }
1069 None => {
1070 document.shift_remove(key);
1071 }
1072 })
1073 }
1074
1075 pub fn set_nested(
1079 &mut self,
1080 scope: Scope,
1081 field: &str,
1082 key: &str,
1083 value: Option<Value>,
1084 ) -> Result<(), SettingsError> {
1085 self.update(scope, |document| {
1086 let mut object = match document.get(field) {
1087 Some(Value::Object(object)) => object.clone(),
1088 _ => Map::new(),
1089 };
1090 match &value {
1091 Some(value) => object.insert(key.to_owned(), value.clone()),
1092 None => object.shift_remove(key),
1093 };
1094 document.insert(field.to_owned(), Value::Object(object));
1095 })
1096 }
1097
1098 fn update(
1102 &mut self,
1103 scope: Scope,
1104 change: impl Fn(&mut Map<String, Value>),
1105 ) -> Result<(), SettingsError> {
1106 let (document, path, broken) = match scope {
1107 Scope::Global => (&mut self.global, &self.global_path, self.broken[0]),
1108 Scope::Project => (&mut self.project, &self.project_path, self.broken[1]),
1109 };
1110 let mut written = None;
1111 if !path.as_os_str().is_empty() && !broken {
1112 if let Some(dir) = path.parent() {
1113 std::fs::create_dir_all(dir).map_err(|source| SettingsError::Io {
1114 path: dir.to_path_buf(),
1115 source,
1116 })?;
1117 }
1118 let failed = |source: std::io::Error| SettingsError::Io {
1119 path: path.clone(),
1120 source,
1121 };
1122 let _lock = FileLock::acquire_blocking(path)
1123 .map_err(|err| failed(std::io::Error::other(err)))?;
1124 let mut toml = is_toml(path)
1127 .then(|| read_toml_document(path))
1128 .transpose()?;
1129 let mut current = match &toml {
1130 Some((document, _)) => toml_settings(path, document)?,
1131 None => read_document(path)?,
1132 };
1133 change(&mut current);
1134 let parse_error = |message: String| SettingsError::Parse {
1135 path: path.clone(),
1136 message,
1137 };
1138 let text = match &mut toml {
1139 Some((document, text)) => {
1140 let leading = document
1143 .is_empty()
1144 .then(|| raw(Some(document.trailing())).to_owned());
1145 reconcile(document.as_table_mut(), ¤t, true, true)
1146 .map_err(parse_error)?;
1147 if let Some(mut leading) = leading.filter(|_| !document.is_empty()) {
1148 if !leading.is_empty() && !leading.ends_with('\n') {
1149 leading.push('\n');
1150 }
1151 document.decor_mut().set_prefix(leading);
1152 document.set_trailing("");
1153 }
1154 as_read(text, document.to_string())
1155 }
1156 None => ConfigFile::Settings
1157 .render(&Value::Object(current.clone()))
1158 .map_err(|err| parse_error(err.to_string()))?,
1159 };
1160 write_atomic(path, text.as_bytes(), 0o666).map_err(failed)?;
1161 written = Some(current);
1162 }
1163 match written {
1164 Some(current) if scope == Scope::Global || self.project_trusted => *document = current,
1166 _ => change(document),
1167 }
1168 self.remerge();
1169 Ok(())
1170 }
1171}
1172
1173#[cfg(test)]
1174mod tests {
1175 use super::*;
1176 use serde_json::json;
1177
1178 #[test]
1179 fn merges_like_pi() {
1180 let global = json!({"compaction": {"enabled": true, "reserveTokens": 100}, "defaultTools": ["read", "bash"], "theme": "dark"});
1181 let project =
1182 json!({"compaction": {"reserveTokens": 5}, "defaultTools": ["+grep", "-bash"]});
1183 let mut merged = deep_merge(global.as_object().unwrap(), project.as_object().unwrap());
1184 merged.insert(
1185 "defaultTools".into(),
1186 merge_default_tools(global.get("defaultTools"), project.get("defaultTools")).unwrap(),
1187 );
1188 assert_eq!(
1189 Value::Object(merged),
1190 json!({"compaction": {"enabled": true, "reserveTokens": 5}, "defaultTools": ["read", "bash", "+grep", "-bash"], "theme": "dark"})
1191 );
1192 assert_eq!(
1193 merge_default_tools(Some(&json!(["read"])), Some(&json!(["ls"]))),
1194 Some(json!(["ls"]))
1195 );
1196 let resolved = |global: &Value, project: &Value| {
1198 let mut manager = SettingsManager::in_memory();
1199 manager.global = Map::from_iter([("defaultTools".to_owned(), global.clone())]);
1200 manager.project = Map::from_iter([("defaultTools".to_owned(), project.clone())]);
1201 manager.default_tools().unwrap()
1202 };
1203 let (global, project) = (&global["defaultTools"], &project["defaultTools"]);
1204 assert_eq!(resolved(global, project), ["read", "grep"]);
1205 let plain = json!(["read", "+ls"]);
1206 assert_eq!(resolved(&plain, &json!(["+find"])), ["read", "ls", "find"]);
1207 let modifiers = json!(["-bash"]);
1208 assert_eq!(
1209 resolved(&modifiers, &json!(["+grep"])),
1210 ["read", "edit", "write", "grep"]
1211 );
1212 assert!(resolved(&json!([]), &json!([])).is_empty());
1214 assert!(resolved(&plain, &json!("read")).is_empty());
1215 assert_eq!(SettingsManager::in_memory().default_tools(), None);
1216 }
1217
1218 #[test]
1220 fn migrates_like_pi() {
1221 let path = Path::new(env!("CARGO_MANIFEST_DIR"))
1222 .join("../../tests/fixtures/pi/settings/migrations.json");
1223 let cases: Vec<Value> =
1224 serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
1225 let dir =
1226 std::env::temp_dir().join(format!("yapi-settings-migrate-{}", std::process::id()));
1227 std::fs::create_dir_all(&dir).unwrap();
1228 let file = dir.join("settings.json");
1229 for case in cases {
1230 std::fs::write(&file, case["input"].as_str().unwrap()).unwrap();
1231 let mut manager = SettingsManager::load(&dir, &dir.join("cwd"), false);
1232 manager
1233 .set(Scope::Global, "lastChangelogVersion", Some(json!("1.0.0")))
1234 .unwrap();
1235 assert_eq!(
1236 std::fs::read_to_string(&file).unwrap(),
1237 case["output"].as_str().unwrap(),
1238 "{}",
1239 case["name"]
1240 );
1241 }
1242 std::fs::write(&file, r#"{"httpProxy": "http://proxy:8080"}"#).unwrap();
1244 assert_eq!(http_proxy(&dir).as_deref(), Some("http://proxy:8080"));
1245 std::fs::remove_dir_all(&dir).unwrap();
1246 assert_eq!(http_proxy(&dir), None);
1247 }
1248
1249 #[tokio::test]
1250 async fn invalid_timeouts_fail() {
1251 let mut manager = SettingsManager::in_memory();
1252 manager.global =
1253 json!({"httpIdleTimeoutMs": " 1500.7 ", "websocketConnectTimeoutMs": "soon"})
1254 .as_object()
1255 .unwrap()
1256 .clone();
1257 assert_eq!(manager.http_idle_timeout_ms(), Ok(1500));
1258 assert_eq!(
1259 manager.websocket_connect_timeout_ms(),
1260 Err("Invalid websocketConnectTimeoutMs setting: soon".to_owned())
1261 );
1262 manager.global = json!({"httpIdleTimeoutMs": -1, "websocketConnectTimeoutMs": "disabled"})
1263 .as_object()
1264 .unwrap()
1265 .clone();
1266 assert_eq!(
1267 manager.http_idle_timeout_ms(),
1268 Err("Invalid httpIdleTimeoutMs setting: -1".to_owned())
1269 );
1270 assert_eq!(manager.websocket_connect_timeout_ms(), Ok(Some(0)));
1271 manager.global = Map::new();
1272 assert_eq!(manager.http_idle_timeout_ms(), Ok(300_000));
1273 assert_eq!(manager.websocket_connect_timeout_ms(), Ok(None));
1274 assert_eq!(manager.compaction_error(None), None);
1275 manager.global = json!({"compaction": {"reserveTokens": 1.5e5, "keepRecentTokens": 1.5}})
1277 .as_object()
1278 .unwrap()
1279 .clone();
1280 let error = "Invalid compaction.keepRecentTokens setting: 1.5. Expected a non-negative safe integer.";
1281 assert_eq!(manager.compaction_error(None).as_deref(), Some(error));
1282
1283 use yapi_ai::faux::{Faux, Response};
1287 use yapi_types::message::{Message, StopReason};
1288 let faux = Faux::new([
1289 Response::tool_call("t", "missing", json!({})),
1290 Response::text("unused"),
1291 ]);
1292 let model: yapi_types::model::Model = serde_json::from_value(json!({
1293 "id": "m", "name": "M", "api": "faux", "provider": "p", "baseUrl": "",
1294 "reasoning": false, "input": ["text"],
1295 "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
1296 "contextWindow": 1000, "maxTokens": 100,
1297 }))
1298 .unwrap();
1299 let mut registry = yapi_ai::registry::ModelRegistry::builtin();
1300 registry.register_provider("p", vec![model.clone()]);
1301 registry.set_runtime_key("p", "key".into());
1302 let mut apis = yapi_ai::api::Apis::default();
1303 apis.register(std::sync::Arc::new(faux.clone()));
1304 let session =
1305 crate::agent_session::AgentSession::new(crate::agent_session::SessionConfig {
1306 cwd: "/work".into(),
1307 agent_dir: "/agent".into(),
1308 settings: manager,
1309 registry,
1310 apis,
1311 session: crate::session::SessionManager::in_memory(Path::new("/work")),
1312 model: Some(model),
1313 thinking_level: yapi_types::message::ThinkingLevel::Off,
1314 tools: Vec::new(),
1315 extensions: Vec::new(),
1316 include_extension_tools: false,
1317 allowed_tools: None,
1318 excluded_tools: Vec::new(),
1319 resources: crate::agent_session::Resources::default(),
1320 docs: crate::docs::Locations::default(),
1321 });
1322 assert_eq!(session.prompt("go", Vec::new()).await, Err(error.into()));
1323 let Some(Message::Assistant(failed)) = session.messages().pop() else {
1324 panic!("no failed response");
1325 };
1326 assert_eq!(
1327 (failed.stop_reason, failed.error_message.as_deref()),
1328 (StopReason::Error, Some(error))
1329 );
1330 assert_eq!(session.prompt("again", Vec::new()).await, Err(error.into()));
1331 assert_eq!(faux.requests().len(), 1);
1332 }
1333
1334 #[test]
1335 fn invalid_fields_keep_valid() {
1336 let mut manager = SettingsManager::in_memory();
1337 manager.global = json!({
1338 "theme": "dark",
1339 "retry": "often",
1340 "steeringMode": "all",
1341 "markdown": {"codeBlockIndent": " ", "mermaid": "bogus"},
1342 "compaction": {"enabled": "", "reserveTokens": "16384", "keepRecentTokens": 2e4},
1343 "branchSummary": {"skipPrompt": true, "reserveTokens": 1.5e5},
1344 "modelThinkingLevels": {"a/b": "high", "a/c": "extreme"},
1345 "terminal": {"imageWidthCells": 40.7},
1346 "outputPad": 0.5,
1347 })
1348 .as_object()
1349 .unwrap()
1350 .clone();
1351 manager.remerge();
1352 let settings = manager.settings();
1353 assert_eq!(settings.theme.as_deref(), Some("dark"));
1354 assert!(settings.retry.is_none());
1355 let compaction = settings.compaction.as_ref().unwrap();
1358 let tokens = (compaction.reserve_tokens, compaction.keep_recent_tokens);
1359 assert_eq!(
1360 (compaction.enabled, tokens),
1361 (Some(false), (None, Some(20000)))
1362 );
1363 let summary = settings.branch_summary.as_ref().unwrap();
1366 assert_eq!(
1367 (summary.skip_prompt, summary.reserve_tokens),
1368 (Some(true), Some(150_000))
1369 );
1370 let terminal = settings.terminal.as_ref().unwrap();
1371 assert_eq!(
1372 (terminal.image_width_cells, settings.output_pad),
1373 (Some(40), None)
1374 );
1375 assert_eq!(settings.model_thinking_levels.as_ref().unwrap().len(), 1);
1376 assert_eq!(
1377 settings.steering_mode,
1378 Some(yapi_types::settings::QueueMode::All)
1379 );
1380 assert_eq!(
1382 settings.markdown,
1383 Some(yapi_types::settings::MarkdownSettings {
1384 code_block_indent: Some(" ".into()),
1385 mermaid: Some(yapi_types::settings::MermaidMode::Streaming),
1386 })
1387 );
1388 }
1389
1390 #[test]
1391 fn cache_warming_global_only() {
1392 let mut manager = SettingsManager::in_memory();
1393 assert_eq!(manager.cache_warming_mode(), CacheWarming::Streaming);
1394 manager.project = json!({"cacheWarming": "off"}).as_object().unwrap().clone();
1395 manager.remerge();
1396 assert_eq!(manager.cache_warming_mode(), CacheWarming::Streaming);
1397 manager.global = json!({"cacheWarming": "idle"}).as_object().unwrap().clone();
1398 assert_eq!(manager.cache_warming_mode(), CacheWarming::Idle);
1399 manager.global = json!({"cacheWarming": "often"})
1400 .as_object()
1401 .unwrap()
1402 .clone();
1403 assert_eq!(manager.cache_warming_mode(), CacheWarming::Streaming);
1404 }
1405
1406 #[test]
1408 fn concurrent_writes_keep_keys() {
1409 let dir = std::env::temp_dir().join(format!("yapi-settings-two-{}", std::process::id()));
1410 let _ = std::fs::remove_dir_all(&dir);
1411 std::fs::create_dir_all(&dir).unwrap();
1412 let mut first = SettingsManager::load(&dir, &dir.join("cwd"), false);
1413 let mut second = SettingsManager::load(&dir, &dir.join("cwd"), false);
1414 second
1415 .set(Scope::Global, "packages", Some(json!(["npm:foo"])))
1416 .unwrap();
1417 first
1418 .set(Scope::Global, "defaultModel", Some(json!("m")))
1419 .unwrap();
1420 first
1421 .set_nested(Scope::Global, "retry", "enabled", Some(json!(false)))
1422 .unwrap();
1423 assert_eq!(
1424 std::fs::read_to_string(dir.join("settings.json")).unwrap(),
1425 "{\n \"packages\": [\n \"npm:foo\"\n ],\n \"defaultModel\": \"m\",\n \"retry\": {\n \"enabled\": false\n }\n}"
1426 );
1427 assert_eq!(first.document(Scope::Global).len(), 3);
1428 assert!(!dir.join("settings.json.lock").exists());
1429 std::fs::remove_dir_all(&dir).unwrap();
1430 }
1431
1432 #[test]
1435 fn toml_settings_round_trip() {
1436 let dir = std::env::temp_dir().join(format!("yapi-settings-toml-{}", std::process::id()));
1437 let _ = std::fs::remove_dir_all(&dir);
1438 std::fs::create_dir_all(&dir).unwrap();
1439 let toml = dir.join("settings.toml");
1440 let text = "# The model.\ndefaultModel = \"m\" # inline\nqueueMode = \"all\"\nenabledModels = [\"a\"]\n\n[compaction]\n# Kept.\nenabled = true\nreserveTokens = 100\n\n[modelThinkingLevels]\n\"p/a\" = \"high\"\n\"p/b\" = \"low\"\n\n[[packages]]\nsource = \"git:github.com/acme/skills@v2\"\nskills = [\"!legacy/**\"]\n\n[projectOnly]\nthemes = [\"*\"]\n";
1441 std::fs::write(&toml, text).unwrap();
1442 let json = json!({
1443 "defaultModel": "m",
1444 "enabledModels": ["a"],
1445 "compaction": {"enabled": true, "reserveTokens": 100},
1446 "modelThinkingLevels": {"p/a": "high", "p/b": "low"},
1447 "packages": [{"source": "git:github.com/acme/skills@v2", "skills": ["!legacy/**"]}],
1448 "projectOnly": {"themes": ["*"]},
1449 "steeringMode": "all",
1450 });
1451 let mut manager = SettingsManager::load(&dir, &dir.join("cwd"), false);
1452 assert_eq!(manager.file(Scope::Global), toml);
1453 assert_eq!(Value::Object(manager.document(Scope::Global).clone()), json);
1454
1455 manager
1458 .set_nested(
1459 Scope::Global,
1460 "compaction",
1461 "reserveTokens",
1462 Some(json!(200)),
1463 )
1464 .unwrap();
1465 manager.set(Scope::Global, "enabledModels", None).unwrap();
1466 manager
1467 .set_nested(Scope::Global, "modelThinkingLevels", "p/b", None)
1468 .unwrap();
1469 let changed =
1471 json!([{"source": "git:github.com/acme/skills@v3", "skills": ["!legacy/**"]}]);
1472 manager
1473 .set(Scope::Global, "packages", Some(changed))
1474 .unwrap();
1475 assert_eq!(
1476 std::fs::read_to_string(&toml).unwrap(),
1477 "# The model.\ndefaultModel = \"m\" # inline\nsteeringMode = \"all\"\n\n[compaction]\n# Kept.\nenabled = true\nreserveTokens = 200\n\n[modelThinkingLevels]\n\"p/a\" = \"high\"\n\n[[packages]]\nsource = \"git:github.com/acme/skills@v3\"\nskills = [\"!legacy/**\"]\n\n[projectOnly]\nthemes = [\"*\"]\n"
1478 );
1479 let mixed = json!(["npm:x", {"source": "git:github.com/acme/skills@v3"}]);
1483 manager
1484 .set(Scope::Global, "packages", Some(mixed.clone()))
1485 .unwrap();
1486 manager
1487 .set(Scope::Global, "projectOnly", Some(Value::Null))
1488 .unwrap();
1489 let reread = SettingsManager::load(&dir, &dir.join("cwd"), false);
1490 assert_eq!(reread.document(Scope::Global)["packages"], mixed);
1491 assert!(!reread.document(Scope::Global).contains_key("projectOnly"));
1492 assert_eq!(
1493 std::fs::read_to_string(&toml).unwrap(),
1494 "# The model.\ndefaultModel = \"m\" # inline\nsteeringMode = \"all\"\n\npackages = [\n \"npm:x\",\n { source = \"git:github.com/acme/skills@v3\" },\n]\n\n[compaction]\n# Kept.\nenabled = true\nreserveTokens = 200\n\n[modelThinkingLevels]\n\"p/a\" = \"high\"\n"
1495 );
1496 let null = manager.set(Scope::Global, "list", Some(json!([null])));
1497 assert!(null.is_err(), "{null:?}");
1498 let big = manager.set(Scope::Global, "big", Some(json!(u64::MAX)));
1499 assert!(big.is_err(), "{big:?}");
1500
1501 let themes = r#"{ source = "npm:@acme/pi-themes", skills = [] }"#;
1504 let tables = "theme = \"g\"\n\n# Themes from npm, skills off.\n[[packages]] # shared\nsource = \"npm:@acme/pi-themes\" # pinned by the team\n# No skills.\nskills = []\n\n# My local tools.\n[[packages]]\nsource = \"../pkgs/local\"\nprompts = []\n";
1505 let inline = format!("packages = [\n \"../pkgs/local\", # local tools\n {themes},\n]\n");
1506 let commented =
1507 "packages = [\n # First.\n \"a\", # one\n \"b\", # two\n \"c\" # three\n]\n";
1508 for (text, packages, expected) in [
1509 (
1511 tables,
1512 json!([{"source": "../pkgs/local", "prompts": []}]),
1513 "theme = \"g\"\n\n# My local tools.\n[[packages]]\nsource = \"../pkgs/local\"\nprompts = []\n".to_owned(),
1514 ),
1515 (
1517 tables,
1518 json!([{"source": "../pkgs/local", "prompts": []}, {"source": "npm:@acme/pi-themes", "skills": []}]),
1519 "theme = \"g\"\n\n# My local tools.\n[[packages]]\nsource = \"../pkgs/local\"\nprompts = []\n\n# Themes from npm, skills off.\n[[packages]] # shared\nsource = \"npm:@acme/pi-themes\" # pinned by the team\n# No skills.\nskills = []\n".to_owned(),
1520 ),
1521 (
1522 &inline,
1523 json!(["../pkgs/local", {"source": "npm:@acme/pi-themes", "skills": []}, "../pkgs/new"]),
1524 format!("packages = [\n \"../pkgs/local\", # local tools\n {themes},\n \"../pkgs/new\",\n]\n"),
1525 ),
1526 (
1527 &inline,
1528 json!([{"source": "npm:@acme/pi-themes", "skills": []}]),
1529 format!("packages = [\n {themes},\n]\n"),
1530 ),
1531 (
1532 commented,
1533 json!(["a", "c", "d"]),
1534 "packages = [\n # First.\n \"a\", # one\n \"c\", # three\n \"d\"\n]\n".to_owned(),
1535 ),
1536 (
1537 "packages = [\"a\", \"b\"] # kept\n",
1538 json!(["b", "c"]),
1539 "packages = [\"b\", \"c\"] # kept\n".to_owned(),
1540 ),
1541 (
1545 tables,
1546 json!([{"source": "npm:@acme/pi-themes", "skills": []}, {"source": "../pkgs/local", "prompts": []}, "../pkgs/new"]),
1547 format!("theme = \"g\"\n\n# Themes from npm, skills off.\npackages = [\n # shared\n # pinned by the team\n # No skills.\n {themes},\n # My local tools.\n {{ source = \"../pkgs/local\", prompts = [] }},\n \"../pkgs/new\",\n]\n"),
1548 ),
1549 (
1551 "[[packages]]\nsource = \"../pkgs/local\"\nskills = [\n # keep this\n \"lpskill\", # and this\n]\n",
1552 json!([{"source": "../pkgs/local", "skills": ["lpskill"]}, "../pkgs/new"]),
1553 "packages = [\n # keep this\n # and this\n { source = \"../pkgs/local\", skills = [\"lpskill\"] },\n \"../pkgs/new\",\n]\n".to_owned(),
1554 ),
1555 (
1557 "[[packages]]\nsource = \"npm:x\"\n\n# Kept.\n[[packages]]\nsource = \"npm:y\"\n",
1558 json!([{"source": "npm:y"}]),
1559 "# Kept.\n[[packages]]\nsource = \"npm:y\"\n".to_owned(),
1560 ),
1561 (
1563 "packages = [\"npm:a\"]\n",
1564 json!(["npm:a", "../pkg2"]),
1565 "packages = [\"npm:a\", \"../pkg2\"]\n".to_owned(),
1566 ),
1567 (
1570 "packages = [\"a\",\n \"b\"]\n",
1571 json!(["a", "b", "c"]),
1572 "packages = [\"a\",\n \"b\",\n \"c\"]\n".to_owned(),
1573 ),
1574 (
1575 "packages = [\n \"a\"]\n",
1576 json!(["z", "a", "c"]),
1577 "packages = [\n \"z\",\n \"a\",\n \"c\"]\n".to_owned(),
1578 ),
1579 (
1582 "packages = [\"a\",\n]\n",
1583 json!(["a", "c"]),
1584 "packages = [\"a\",\n \"c\",\n]\n".to_owned(),
1585 ),
1586 (
1587 "packages = [\"a\" # one\n]\n",
1588 json!(["a", "c"]),
1589 "packages = [\"a\", # one\n \"c\"\n]\n".to_owned(),
1590 ),
1591 (
1594 "packages = [\"a\", # main\n \"b\"]\n",
1595 json!(["b", "a"]),
1596 "packages = [\"b\",\n \"a\" # main\n]\n".to_owned(),
1597 ),
1598 (
1599 "packages = [\"a\", # main\n \"b\", # two\n \"c\"]\n",
1600 json!(["b", "a", "c"]),
1601 "packages = [\"b\", # two\n \"a\", # main\n \"c\"]\n".to_owned(),
1602 ),
1603 (
1605 "# only a comment\n",
1606 json!(["npm:x"]),
1607 "# only a comment\npackages = [\"npm:x\"]\n".to_owned(),
1608 ),
1609 (
1610 "# no line break",
1611 json!(["npm:x"]),
1612 "# no line break\npackages = [\"npm:x\"]\n".to_owned(),
1613 ),
1614 (
1616 "\u{feff}packages = [\"a\"]\n",
1617 json!(["a", "b"]),
1618 "\u{feff}packages = [\"a\", \"b\"]\n".to_owned(),
1619 ),
1620 (
1622 "# Mine.\r\npackages = [\r\n \"a\", # one\r\n]\r\ntheme = \"g\"\r\n",
1623 json!(["a", "b"]),
1624 "# Mine.\r\npackages = [\r\n \"a\", # one\r\n \"b\",\r\n]\r\ntheme = \"g\"\r\n".to_owned(),
1625 ),
1626 (
1629 "theme = \"g\"\n\n# The only package.\n[[packages]]\nsource = \"npm:x\"\n",
1630 json!([]),
1631 "theme = \"g\"\n\n# The only package.\npackages = []\n".to_owned(),
1632 ),
1633 ] {
1634 std::fs::write(&toml, text).unwrap();
1635 let mut manager = SettingsManager::load(&dir, &dir.join("cwd"), false);
1636 manager
1637 .set(Scope::Global, "packages", Some(packages.clone()))
1638 .unwrap();
1639 assert_eq!(std::fs::read_to_string(&toml).unwrap(), expected, "{text}");
1640 let reread = SettingsManager::load(&dir, &dir.join("cwd"), false);
1641 assert_eq!(reread.document(Scope::Global)["packages"], packages);
1642 }
1643
1644 std::fs::write(&toml, "theme = 1979-05-27\n").unwrap();
1646 let mut dated = SettingsManager::load(&dir, &dir.join("cwd"), false);
1647 assert!(
1648 dated.errors()[0].contains("is a date or time"),
1649 "{:?}",
1650 dated.errors()
1651 );
1652 dated
1653 .set(Scope::Global, "theme", Some(json!("dark")))
1654 .unwrap();
1655 assert_eq!(
1656 std::fs::read_to_string(&toml).unwrap(),
1657 "theme = 1979-05-27\n"
1658 );
1659 std::fs::remove_dir_all(&dir).unwrap();
1660 }
1661
1662 #[test]
1665 fn both_files_conflict() {
1666 let dir = std::env::temp_dir().join(format!("yapi-settings-both-{}", std::process::id()));
1667 let _ = std::fs::remove_dir_all(&dir);
1668 let project = dir.join("cwd");
1669 for folder in [dir.clone(), project.join(PROJECT_DIR)] {
1670 std::fs::create_dir_all(&folder).unwrap();
1671 std::fs::write(folder.join("settings.json"), "{\"theme\": \"dark\"}").unwrap();
1672 std::fs::write(folder.join("settings.toml"), "theme = \"light\"\n").unwrap();
1673 }
1674 let untrusted = SettingsManager::load(&dir, &project, false);
1675 let conflict = untrusted.conflict().unwrap();
1676 assert_eq!(
1677 conflict,
1678 format!(
1679 "Both settings.json and settings.toml are in {}. Keep one of them.",
1680 dir.display()
1681 )
1682 );
1683 std::fs::remove_file(dir.join("settings.toml")).unwrap();
1684 assert_eq!(
1685 SettingsManager::load(&dir, &project, false).conflict(),
1686 None
1687 );
1688 let mut trusted = SettingsManager::load(&dir, &project, true);
1689 assert!(trusted.conflict().unwrap().contains(".yapi"));
1690 assert!(trusted.document(Scope::Project).is_empty());
1691 trusted
1692 .set(Scope::Project, "theme", Some(json!("x")))
1693 .unwrap();
1694 let toml = project.join(PROJECT_DIR).join("settings.toml");
1695 assert_eq!(
1696 std::fs::read_to_string(toml).unwrap(),
1697 "theme = \"light\"\n"
1698 );
1699 std::fs::remove_dir_all(&dir).unwrap();
1700 }
1701}