Skip to main content

yapi_core/
settings.rs

1//! Settings: global `settings.json` merged with the trusted project's.
2//!
3//! Port of `packages/coding-agent/src/core/settings-manager.ts` in pi `v1.0.0`.
4//! Each scope is an order-preserving document. Edits re-read its file under the
5//! file's lock, change it and write it back the way pi does. Readers use the
6//! typed view of the merged document.
7
8use 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/// Which file a setting lives in.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum Scope {
22    /// `<agent>/settings.json`, or `settings.toml` there.
23    Global,
24    /// `<project>/.yapi/settings.json`, or `settings.toml` there.
25    Project,
26}
27
28/// The TOML form of a folder's settings file, which yapi reads and writes
29/// in place of `settings.json`.
30pub const TOML_FILE: &str = "settings.toml";
31
32/// Settings errors.
33#[derive(Debug, thiserror::Error)]
34pub enum SettingsError {
35    /// The file could not be read or written.
36    #[error("{path}: {source}")]
37    Io {
38        /// The file.
39        path: PathBuf,
40        /// The cause.
41        source: std::io::Error,
42    },
43    /// The file is not valid settings JSON or TOML.
44    #[error("Failed to parse {path}: {message}")]
45    Parse {
46        /// The file.
47        path: PathBuf,
48        /// What is wrong.
49        message: String,
50    },
51    /// A folder holds both forms of its settings file.
52    #[error("Both settings.json and settings.toml are in {}. Keep one of them.", dir.display())]
53    Conflict {
54        /// The folder.
55        dir: PathBuf,
56    },
57}
58
59/// The settings file of folder `dir`: `settings.toml` when it holds one,
60/// else `settings.json`, which a first write creates. A folder that holds
61/// both is a [`SettingsError::Conflict`].
62pub 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
74/// The settings document in the `settings.json` or `settings.toml` at
75/// `path`, read as yapi reads it. A missing file is an empty document.
76pub 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
85/// The TOML document at `path` as written, with its text, or an empty one
86/// when there is none.
87fn 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
111/// `written`, which toml_edit wrote for a document read from `text`, with
112/// the byte order mark and line endings of `text`, as toml_edit ends its
113/// lines with `\n`. Blank lines that start it and not `text`, as after
114/// removing the first table, go.
115fn 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
131/// The settings that TOML `document` from `path` holds, as `settings.json`
132/// would hold them, migrated as pi migrates JSON. A date or time is no
133/// setting, so it fails.
134fn 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
190/// `value` as a TOML value: objects as inline tables, `null` keys left out.
191fn 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
218/// Brings `table` in line with `new`, key by key, so unchanged keys keep their
219/// comments and layout: keys `new` lacks or sets to `null` go, changed ones
220/// change in place, and new ones are added. `root` makes new objects
221/// standard tables rather than inline ones. `lines` says the table's keys
222/// start their lines, as in a standard table.
223fn 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        // shortcut: the text before a value on its line is known only where
239        // keys start lines, not in dotted keys or inline tables; upgrade if
240        // their arrays need it.
241        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        // New, or a table that becomes a value: a key of its own.
252        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            // An array of tables that becomes an inline array keeps the
261            // comments above its headers.
262            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
283/// The text of a decor part, which a parsed or built document holds.
284fn raw(text: Option<&toml_edit::RawString>) -> &str {
285    text.and_then(toml_edit::RawString::as_str)
286        .unwrap_or_default()
287}
288
289/// `text` split after its first line break, into the end of a line and the
290/// lines after it. Without a line break, all of it is the rest.
291fn split_line(text: &str) -> (&str, &str) {
292    text.find('\n')
293        .map_or(("", text), |at| text.split_at(at + 1))
294}
295
296/// For each entry of `new`, the index of the entry of `old` whose comments
297/// it keeps: an equal one no other entry keeps, wherever it was, else the
298/// one at its own index, when no entry keeps that.
299fn 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
318/// Brings inline `array`, which holds values, in line with `items`. A value
319/// keeps the comment lines above it and the rest of its own line, as its
320/// decor holds them, and every value the indent of its place. A new value
321/// takes the layout of the last one, on a line of its own in a multi-line
322/// array. `line` is the text before `[` on its line, when known.
323fn 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    // Without a trailing comma, the last value's suffix ends its line.
335    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    // What follows `[`, the lines above each value, and the end of each
340    // value's line.
341    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    // In a multi-line array, a new value and the one after it start their
357    // own lines, as does a value after the old last one. A new last value
358    // ends its line as the last one did.
359    let multi_line = prefixes.iter().any(|prefix| prefix.contains('\n')) || after.contains('\n');
360    let new_tail = if multi_line { "\n" } else { "" };
361    // The first value's column, as blanks that keep the line's tabs.
362    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        // One value on the line of `[` has no separator to copy: on lines
370        // of their own, the values after it line up with it.
371        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
414/// `items`, the entries of array of tables `tables`, as a multi-line inline
415/// array. Each value keeps, as lines above it, the comments of the table it
416/// was: those on its header line, between its keys, at the ends of their
417/// lines and inside their arrays, and those above its header, except the
418/// first table's, which go above the key.
419fn 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
463/// Adds to `texts` the decor inside `value`, where an array holds the
464/// comments between its values. An inline table holds none.
465fn 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
476/// Brings `item` in line with `value` in place, or returns false when it
477/// must give way to a new key, as a table does to a plain value. `line` is
478/// the text before the value on its line, when known.
479fn 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        // An array of tables stays one while every entry is a table.
498        // Empty, it would print as nothing and lose its key. Each entry
499        // keeps the table of an equal one, with its comments.
500        (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            // The file prints tables by position, so moved ones swap theirs.
510            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            // A replaced value keeps the comments around it.
539            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/// Both scopes and their merged view.
550#[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    /// Both scopes merged as written, as pi's `settings`.
559    document: Map<String, Value>,
560    merged: Settings,
561    /// Files that failed to load, by scope. They read as empty and are never
562    /// written, so a broken file is not overwritten.
563    broken: [bool; 2],
564    errors: Vec<String>,
565    /// A folder read that holds both settings files.
566    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
603/// pi's `migrateSettings`: settings in older forms take their current
604/// ones in the document, which is written back that way the next time its
605/// scope is saved. Keys move as JavaScript moves them: a new key goes last
606/// and a reassigned one keeps its place.
607fn 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
651/// JavaScript's `String(value)` for a JSON value.
652pub 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
668/// pi's deep merge: objects merge recursively, anything else replaces.
669fn 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
687/// `defaultTools`: a list of plain names, or any other value, replaces the
688/// inherited one. A list of only `+name` and `-name` entries is appended to it.
689fn 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
705/// pi's `resolveDefaultTools`: the plain names, or the default tools when a
706/// list has only modifiers, then each `+name` adds and `-name` removes a tool
707/// in list order.
708fn 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
731/// Whether [`Settings`] reads `value` at `path`.
732fn 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
739/// The fields of `object`, found at `path` in the settings, that [`Settings`]
740/// reads: a field of the wrong type is left out alone, also inside an object,
741/// as pi reads each field on its own.
742fn 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
761/// The global `httpProxy` setting in `agent_dir`, when its settings file
762/// reads and sets one.
763pub 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
772/// pi's `applyHttpProxySettings`: the global `httpProxy` setting stands in
773/// for unset proxy variables, for yapi and for chest alike.
774pub 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    /// Loads both scopes, the project's from `project_dir`, and that only
781    /// when `project_trusted`.
782    /// A folder that holds both `settings.json` and `settings.toml` loads as
783    /// empty, is never written and sets [`SettingsManager::conflict`].
784    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        // An untrusted project's folder is never read, so it never conflicts.
789        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        // pi reports a file that fails to load and continues without it.
805        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    /// The file that holds `scope`'s settings: `settings.json` or
846    /// `settings.toml`.
847    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    /// Why the settings must not be used: a folder they read holds both
855    /// `settings.json` and `settings.toml`. Commands stop with it.
856    pub fn conflict(&self) -> Option<&str> {
857        self.conflict.as_deref()
858    }
859
860    /// Settings with no files behind them.
861    pub fn in_memory() -> SettingsManager {
862        SettingsManager::default()
863    }
864
865    /// Why settings files failed to load, as pi's warnings word them.
866    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        // pi reads these switches by truthiness, so a value of another type
880        // keeps the rest of its object. `null` reads as unset.
881        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        // Unknown or invalid fields are ignored, as in pi.
892        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    /// The merged settings.
899    pub fn settings(&self) -> &Settings {
900        &self.merged
901    }
902
903    /// pi's `getDefaultTools`: the tools the merged `defaultTools` enables, or
904    /// `None` when no scope sets it. A value that is not a list enables none,
905    /// and entries that are not strings are skipped.
906    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    /// Both scopes merged as written, values of any type included: pi's
919    /// `settings`.
920    pub fn merged_document(&self) -> &Map<String, Value> {
921        &self.document
922    }
923
924    /// `defaultThinkingLevel`, if set: a value that names no level becomes
925    /// `off`, the first level pi's clamp offers.
926    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    /// pi's `getHttpIdleTimeoutMs`: `httpIdleTimeoutMs` in milliseconds, as a
940    /// number, a numeric string or `"disabled"` (0), five minutes when unset.
941    pub fn http_idle_timeout_ms(&self) -> Result<u64, String> {
942        Ok(self.timeout_ms("httpIdleTimeoutMs")?.unwrap_or(300_000))
943    }
944
945    /// pi's `getWebSocketConnectTimeoutMs`: `websocketConnectTimeoutMs`, read
946    /// as `httpIdleTimeoutMs` is, or `None` when unset.
947    pub fn websocket_connect_timeout_ms(&self) -> Result<Option<u64>, String> {
948        self.timeout_ms("websocketConnectTimeoutMs")
949    }
950
951    /// `retry.provider.timeoutMs` when it is a number, unchecked, as pi
952    /// passes it on to providers.
953    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    /// pi's `parseTimeoutSetting` of setting `key`: milliseconds as a
965    /// number, a numeric string or `"disabled"` (0). `None` when unset, and
966    /// pi's error for any other value.
967    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    /// pi's `getCompactionTokenSetting` checks: the error for a
985    /// `compaction` token setting, or `model`'s override of one, that is not
986    /// a non-negative safe integer.
987    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    /// pi's `getCacheWarmingMode`: `cacheWarming` from the global settings
1029    /// only, because each refresh costs money, defaulting to `streaming` when
1030    /// unset or invalid.
1031    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    /// The directory whose `.yapi` folder holds the project's files: the
1039    /// project's settings, resources and system prompt files.
1040    pub fn project_dir(&self) -> &Path {
1041        &self.project_dir
1042    }
1043
1044    /// Whether the project scope is loaded.
1045    pub fn project_trusted(&self) -> bool {
1046        self.project_trusted
1047    }
1048
1049    /// The settings document of `scope` as written, empty for an untrusted
1050    /// project.
1051    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    /// Sets a top-level key in a scope and writes that file. `None` removes it.
1059    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    /// Sets `key` inside the object setting `field` of `scope`, keeping its
1076    /// other keys, and writes that scope's file. `None` removes the key and
1077    /// keeps the object.
1078    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    /// Applies `change` to `scope` and writes its file as pi does: under the
1099    /// file's lock, to the file as it is now, so keys other processes changed
1100    /// since loading are kept. The scope then holds the file's document.
1101    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            // A TOML file is read once, as a document that keeps its
1125            // comments and layout.
1126            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                    // Without keys, a file's comments are its trailing
1141                    // text, which new keys would come before.
1142                    let leading = document
1143                        .is_empty()
1144                        .then(|| raw(Some(document.trailing())).to_owned());
1145                    reconcile(document.as_table_mut(), &current, 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            // An untrusted project's other settings stay unloaded.
1165            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        // pi's getDefaultTools resolves the merged list.
1197        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        // An empty list, or a value that is not a list, enables no tool.
1213        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    /// pi's legacy setting forms, migrated as pi writes them back.
1219    #[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        // chest and yapi read the proxy from the same file.
1243        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        // pi's compaction getters take whole numbers only.
1276        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        // As pi's next-turn hook throws it, the run ends between turns with a
1284        // failed response. As its check before a prompt throws it, the next
1285        // prompt fails before its request.
1286        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        // A wrongly typed nested field drops alone, and pi reads a switch of
1356        // another type by truthiness.
1357        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        // A whole float is its integer. pi rounds a fraction down where it
1364        // counts cells, and reads any output padding but 0 as 1.
1365        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        // pi reads a mermaid mode it does not know as streaming.
1381        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    /// Two processes that loaded the same file keep each other's changes.
1407    #[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    /// `settings.toml` holds what `settings.json` holds, and writes change
1433    /// only what changed, keeping comments and layout.
1434    #[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        // A nested change, a removal, a nested removal and the migration of
1456        // `queueMode` leave the rest of the file as it was.
1457        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        // An object entry changes in place, keeping the array of tables.
1470        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        // A plain source makes `packages` a multi-line inline array, and
1480        // `null` removes a key, while one in an array and an integer TOML
1481        // can't hold fail.
1482        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        // Each kept entry of `packages` keeps its comments and line breaks,
1502        // whether a table or a value, and a new one the layout of the last.
1503        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            // Removing a table takes its own comments along.
1510            (
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            // Tables that swap keep their comments, in the new order.
1516            (
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            // A plain source makes the tables a multi-line array, with the
1542            // first table's comments above the key and the others' on their
1543            // values, as are those inside each table.
1544            (
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            // So are the comments inside a table's arrays.
1550            (
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            // A removed first table leaves no blank line at the start.
1556            (
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            // One value on one line has no separator to copy.
1562            (
1563                "packages = [\"npm:a\"]\n",
1564                json!(["npm:a", "../pkg2"]),
1565                "packages = [\"npm:a\", \"../pkg2\"]\n".to_owned(),
1566            ),
1567            // In a multi-line array, a new value and the one after it start
1568            // their own lines, even where `]` follows the last value.
1569            (
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            // On lines of their own, values after one on the line of `[`
1580            // line up with it.
1581            (
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            // A value that moves keeps its comments, and one after the old
1592            // last value starts its own line.
1593            (
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            // New keys follow the comments of a file without keys.
1604            (
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            // A file with a byte order mark keeps it.
1615            (
1616                "\u{feff}packages = [\"a\"]\n",
1617                json!(["a", "b"]),
1618                "\u{feff}packages = [\"a\", \"b\"]\n".to_owned(),
1619            ),
1620            // A file with CRLF line endings keeps them.
1621            (
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            // An array of tables emptied keeps its key, as `packages = []`,
1627            // and the comments above it.
1628            (
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        // A date is no setting, so the file is reported and never written.
1645        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    /// A folder with both settings files loads as empty, is never written
1663    /// and reports the conflict, unless it is an untrusted project's.
1664    #[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}