Skip to main content

forge_workspace/
settings_write.rs

1//! The comment-preserving writer behind the settings editor.
2//!
3//! An edit names a catalogue pattern and the concrete path it fills; the
4//! pattern says where the item sits, so a display path with dots in a name
5//! is never re-parsed as TOML. One pass applies every edit to the current
6//! text, and the caller validates the candidate with the boot loader before
7//! it replaces the file.
8
9use forge_primitives::settings::{
10    EditKind, SettingsDocument, SettingsEdit, SettingsEditOp, SettingsEditRequest, SettingsKey,
11};
12use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, Value};
13
14/// Why an edit could not be applied.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub(crate) struct WriteError {
17    /// The loader-facing words, and the key to anchor them at where one is
18    /// known.
19    pub message: String,
20    pub key_path: Option<String>,
21}
22
23fn invalid(message: impl Into<String>, key_path: Option<String>) -> WriteError {
24    WriteError { message: message.into(), key_path }
25}
26
27/// Apply every edit to the current text.
28pub(crate) fn apply(
29    current: &str,
30    edits: &[SettingsEditRequest],
31    document: &SettingsDocument,
32) -> Result<String, WriteError> {
33    // **One path, one edit.** Two edits for one key have no defined winner,
34    // and the second would silently undo the first while the save reported a
35    // change; the batch is refused rather than guessed at.
36    let mut named = std::collections::HashSet::with_capacity(edits.len());
37    for edit in edits {
38        if !named.insert(edit.key_path.as_str()) {
39            return Err(invalid(
40                format!(
41                    "`{}` appears twice in one save; a batch names each key once",
42                    edit.key_path,
43                ),
44                Some(edit.key_path.clone()),
45            ));
46        }
47    }
48    let mut doc: DocumentMut = current
49        .parse()
50        .map_err(|error| invalid(format!("the current file does not parse: {error}"), None))?;
51    // **A rename is applied last.** Locating an entry by its pre-edit name
52    // (an org's, a project's, an account's) fails once that name moved, so a
53    // batch that renames and edits the same entry applies the edits first.
54    // A project's rename goes before its org's: the project's own identity is
55    // the org's pre-edit name.
56    let mut rest: Vec<&SettingsEditRequest> = Vec::new();
57    let mut nested_renames: Vec<&SettingsEditRequest> = Vec::new();
58    let mut entry_renames: Vec<&SettingsEditRequest> = Vec::new();
59    for edit in edits {
60        match identity_pattern(edit, document) {
61            None => rest.push(edit),
62            Some("orgs[].projects[].name") => nested_renames.push(edit),
63            Some(_) => entry_renames.push(edit),
64        }
65    }
66    for edit in rest {
67        apply_one(&mut doc, edit, document)?;
68    }
69    for edit in nested_renames {
70        apply_one(&mut doc, edit, document)?;
71    }
72    for edit in entry_renames {
73        apply_one(&mut doc, edit, document)?;
74    }
75    let text = doc.to_string();
76    // toml_edit renders `\n`; a CRLF file comes back as it went in, including
77    // inside a multi-line string, where the newline is content rather than
78    // layout. **The layout is read from the first newline**: a CRLF inside a
79    // string in an otherwise LF file is content and must not decide it.
80    let crlf = current
81        .find('\n')
82        .is_some_and(|at| at > 0 && current.as_bytes().get(at - 1) == Some(&b'\r'));
83    Ok(if crlf { text.replace("\r\n", "\n").replace('\n', "\r\n") } else { text })
84}
85
86/// The catalogue pattern of an edit when it is one of the fields that names
87/// its entry, which a batch must apply after the edit's other changes.
88fn identity_pattern<'a>(
89    edit: &'a SettingsEditRequest,
90    document: &'a SettingsDocument,
91) -> Option<&'a str> {
92    let pattern = if edit.pattern.is_empty() {
93        find_key(document, &edit.key_path).map(|key| key.pattern.as_str())?
94    } else {
95        edit.pattern.as_str()
96    };
97    matches!(
98        pattern,
99        "orgs[].name" | "orgs[].projects[].name" | "accounts[].display_name" | "slack[].workspace"
100    )
101    .then_some(pattern)
102}
103
104fn apply_one(
105    doc: &mut DocumentMut,
106    edit: &SettingsEditRequest,
107    document: &SettingsDocument,
108) -> Result<(), WriteError> {
109    match edit.op {
110        SettingsEditOp::Set => set_key(doc, edit, document),
111        SettingsEditOp::Remove if edit.pattern.is_empty() => remove_entry(doc, edit),
112        SettingsEditOp::Remove => remove_key(doc, edit, document),
113        SettingsEditOp::Add => add_entry(doc, edit),
114    }
115}
116
117fn find_key<'a>(document: &'a SettingsDocument, path: &str) -> Option<&'a SettingsKey> {
118    document.areas.iter().flat_map(|area| &area.keys).find(|key| key.path == path)
119}
120
121fn set_key(
122    doc: &mut DocumentMut,
123    edit: &SettingsEditRequest,
124    document: &SettingsDocument,
125) -> Result<(), WriteError> {
126    let absent;
127    let key: &SettingsKey = match find_key(document, &edit.key_path) {
128        Some(found) => {
129            if !edit.pattern.is_empty() && edit.pattern != found.pattern {
130                return Err(invalid(
131                    format!("`{}` is not the key it was loaded as", edit.key_path),
132                    Some(edit.key_path.clone()),
133                ));
134            }
135            found
136        }
137        // **An optional key the config does not set emits no row.** The
138        // edit carries the catalogue pattern that would create it, and the
139        // writer locates from the pattern rather than the missing row.
140        None if !edit.pattern.is_empty() => {
141            absent =
142                crate::settings::synthetic_key(&edit.key_path, &edit.pattern).ok_or_else(|| {
143                    invalid(
144                        format!("`{}` is not a catalogue key", edit.key_path),
145                        Some(edit.key_path.clone()),
146                    )
147                })?;
148            &absent
149        }
150        None => {
151            return Err(invalid(
152                format!("`{}` is no longer in this config", edit.key_path),
153                Some(edit.key_path.clone()),
154            ));
155        }
156    };
157    let value = edit.value.clone().unwrap_or(serde_json::Value::Null);
158    if value.is_null() {
159        return remove_leaf(doc, key);
160    }
161    let toml = to_toml(&value, key.edit.kind, &key.edit.allowed, &edit.key_path)?;
162    let (steps, leaf) = locate(key).ok_or_else(|| {
163        invalid(
164            format!("`{}` does not fill its catalogue pattern", edit.key_path),
165            Some(edit.key_path.clone()),
166        )
167    })?;
168    let parent = descend(doc, &steps, true).ok_or_else(|| {
169        invalid(
170            format!("`{}` has no table to write into", edit.key_path),
171            Some(edit.key_path.clone()),
172        )
173    })?;
174    parent.set(&leaf, toml);
175    Ok(())
176}
177
178fn remove_key(
179    doc: &mut DocumentMut,
180    edit: &SettingsEditRequest,
181    document: &SettingsDocument,
182) -> Result<(), WriteError> {
183    let key = find_key(document, &edit.key_path).ok_or_else(|| {
184        invalid(
185            format!("`{}` is no longer in this config", edit.key_path),
186            Some(edit.key_path.clone()),
187        )
188    })?;
189    remove_leaf(doc, key)
190}
191
192fn remove_leaf(doc: &mut DocumentMut, key: &SettingsKey) -> Result<(), WriteError> {
193    let (steps, leaf) = locate(key).ok_or_else(|| {
194        invalid(
195            format!("`{}` does not fill its catalogue pattern", key.path),
196            Some(key.path.clone()),
197        )
198    })?;
199    let parent = descend(doc, &steps, false).ok_or_else(|| {
200        invalid(format!("`{}` has no table to remove from", key.path), Some(key.path.clone()))
201    })?;
202    let leftover = parent.remove(&leaf);
203    // The key was the table's last; its comments belong at the table's end.
204    if let Some(prefix) = leftover
205        && let Some(Container::Table(table)) = descend(doc, &steps, false)
206    {
207        keep_suffix(table, &prefix);
208    }
209    Ok(())
210}
211
212/// One step into the document.
213#[derive(Clone, Debug, PartialEq, Eq)]
214enum Step {
215    Key(String),
216    Account(String),
217    Org(String),
218    Project(String),
219    Slack(String),
220}
221
222/// Where a leaf sits: the steps to its parent and the leaf's own key.
223///
224/// **The row's own instance names are tried first, then the path's own
225/// split.** `group` holds the names the row was built from, but `split_row`
226/// cannot tell a name from a key segment: projects `api` and `api.handoff`
227/// both match the row `orgs.Acme.projects.api.handoff.enabled`, and its
228/// longest-name rule picks the sibling. So a group split is used only when
229/// it reassembles the row's path exactly, and the fallback reads the path
230/// against the pattern's literal segments.
231fn locate(key: &SettingsKey) -> Option<(Vec<Step>, String)> {
232    let parts = parts_of(&key.pattern)?;
233    if let Some(found) = locate_from_group(key, &parts) {
234        return Some(found);
235    }
236    locate_from_path(key, &parts)
237}
238
239/// The split from the row's `group`, accepted only when it rebuilds the row's
240/// own path.
241fn locate_from_group(key: &SettingsKey, parts: &[Part]) -> Option<(Vec<Step>, String)> {
242    // `group` also carries a table name for the areas whose rows sit under
243    // one (`slack[].token` groups as `["slack", workspace]`, `server.port`
244    // as `["server"]`), so the names a wildcard takes are its LAST entries.
245    let consuming = parts
246        .iter()
247        .take(parts.len().saturating_sub(1))
248        .filter(|part| matches!(part, Part::Array(_) | Part::Wildcard))
249        .count();
250    let start = key.group.len().checked_sub(consuming)?;
251    let names: Vec<String> = key.group[start..].to_vec();
252    let (steps, leaf, rebuilt) = build(parts, &names, &key.leaf)?;
253    (rebuilt == key.path).then_some((steps, leaf))
254}
255
256/// The split read off the row's path: each wildcard takes the shortest name
257/// that lets the rest of the pattern match, so the pattern's literal
258/// segments anchor it.
259fn locate_from_path(key: &SettingsKey, parts: &[Part]) -> Option<(Vec<Step>, String)> {
260    let mut names = Vec::new();
261    if !match_path(parts, &key.path, &mut names) {
262        return None;
263    }
264    let (steps, leaf, rebuilt) = build(parts, &names, &key.leaf)?;
265    (rebuilt == key.path).then_some((steps, leaf))
266}
267
268/// Turn one set of instance names into steps, and rebuild the path they
269/// should produce.
270fn build(parts: &[Part], names: &[String], leaf_hint: &str) -> Option<(Vec<Step>, String, String)> {
271    let last = parts.last()?;
272    let leaf = match last {
273        Part::Wildcard => leaf_hint.to_owned(),
274        Part::Literal(value) | Part::Array(value) => value.clone(),
275    };
276    let mut next = names.iter();
277    let mut steps = Vec::new();
278    let mut rebuilt = String::new();
279    for (index, part) in parts.iter().enumerate() {
280        if index > 0 {
281            rebuilt.push('.');
282        }
283        match part {
284            Part::Literal(value) => {
285                rebuilt.push_str(value);
286                // The last literal is the leaf itself, which `build` returns.
287                if index + 1 < parts.len() {
288                    steps.push(Step::Key(value.clone()));
289                }
290            }
291            Part::Array(table) => {
292                rebuilt.push_str(table);
293                if index + 1 == parts.len() {
294                    continue;
295                }
296                rebuilt.push('.');
297                let name = next.next()?;
298                rebuilt.push_str(name);
299                steps.push(array_step(table, name)?);
300            }
301            Part::Wildcard => {
302                if index + 1 == parts.len() {
303                    rebuilt.push_str(&leaf);
304                } else {
305                    let name = next.next()?;
306                    rebuilt.push_str(name);
307                    steps.push(Step::Key(name.clone()));
308                }
309            }
310        }
311    }
312    if next.next().is_some() {
313        return None;
314    }
315    Some((steps, leaf, rebuilt))
316}
317
318fn array_step(table: &str, name: &str) -> Option<Step> {
319    Some(match table {
320        "accounts" => Step::Account(name.to_owned()),
321        "orgs" => Step::Org(name.to_owned()),
322        "projects" => Step::Project(name.to_owned()),
323        "slack" => Step::Slack(name.to_owned()),
324        _ => return None,
325    })
326}
327
328/// Whether `path` fills `pattern`, and the instance names it took. The
329/// catalogue's own matcher, so a key the config does not set can still be
330/// resolved back to the pattern that would write it.
331pub(crate) fn match_pattern(pattern: &str, path: &str) -> Option<Vec<String>> {
332    let parts = parts_of(pattern)?;
333    let mut names = Vec::new();
334    match_path(&parts, path, &mut names).then_some(names)
335}
336
337/// Match a pattern against a display path, shortest wildcard names first, and
338/// collect the instance names in wildcard order.
339fn match_path(parts: &[Part], path: &str, names: &mut Vec<String>) -> bool {
340    let Some((part, rest)) = parts.split_first() else {
341        return path.is_empty();
342    };
343    match part {
344        Part::Literal(value) => {
345            if rest.is_empty() {
346                return path == value;
347            }
348            path.strip_prefix(value)
349                .and_then(|tail| tail.strip_prefix('.'))
350                .is_some_and(|tail| match_path(rest, tail, names))
351        }
352        Part::Array(table) => {
353            if rest.is_empty() {
354                return path == table;
355            }
356            let Some(tail) = path.strip_prefix(table).and_then(|tail| tail.strip_prefix('.'))
357            else {
358                return false;
359            };
360            split_then(tail, names, rest)
361        }
362        // A trailing wildcard is the leaf, which `build` takes from the row;
363        // the rebuilt path is what checks it.
364        Part::Wildcard => rest.is_empty() || split_then(path, names, rest),
365    }
366}
367
368/// Try each dot boundary of `path` as a wildcard name, shortest first, and
369/// keep the name only when the rest of the pattern matches.
370fn split_then(path: &str, names: &mut Vec<String>, rest: &[Part]) -> bool {
371    for (at, ch) in path.char_indices() {
372        if ch != '.' || at == 0 {
373            continue;
374        }
375        names.push(path[..at].to_owned());
376        if match_path(rest, &path[at + 1..], names) {
377            return true;
378        }
379        names.pop();
380    }
381    false
382}
383
384#[derive(Clone, Debug)]
385enum Part {
386    Literal(String),
387    /// `name[]`: the table's name and one wildcard for the instance.
388    Array(String),
389    Wildcard,
390}
391
392fn parts_of(pattern: &str) -> Option<Vec<Part>> {
393    let mut parts = Vec::new();
394    for segment in pattern.split('.') {
395        if segment.is_empty() {
396            return None;
397        }
398        if segment == "*" {
399            parts.push(Part::Wildcard);
400        } else if let Some(name) = segment.strip_suffix("[]") {
401            parts.push(Part::Array(name.to_owned()));
402        } else {
403            parts.push(Part::Literal(segment.to_owned()));
404        }
405    }
406    (!parts.is_empty()).then_some(parts)
407}
408
409/// The container a leaf's parent is: a table, or an inline table
410/// (`model_slugs = { ... }`).
411enum Container<'a> {
412    Table(&'a mut Table),
413    Inline(&'a mut InlineTable),
414}
415
416impl Container<'_> {
417    fn set(self, key: &str, value: Value) {
418        match self {
419            Self::Table(table) => match table.get_mut(key) {
420                Some(item) => set_item_value(item, value),
421                None => {
422                    table.insert(key, Item::Value(value));
423                }
424            },
425            Self::Inline(table) => match table.get_mut(key) {
426                Some(existing) => {
427                    let decor = existing.decor().clone();
428                    let mut value = value;
429                    *value.decor_mut() = decor;
430                    *existing = value;
431                }
432                None => {
433                    table.insert(key, value);
434                }
435            },
436        }
437    }
438
439    /// Remove a key, moving its comments onto the next key in the table.
440    /// When there is no next key the comments are handed back, because
441    /// whether the table's own suffix renders depends on the table: an
442    /// implicit `[models]` parent never renders one.
443    fn remove(self, key: &str) -> Option<String> {
444        match self {
445            Self::Table(table) => {
446                // **A key's comments live in its decor prefix**, so removing
447                // the key must move them rather than delete them: onto the
448                // key that follows it, or the table's own trailing text when
449                // it was the last one.
450                let next = next_key_after(table, key);
451                // Read the leading text before the removal: a comment above
452                // a key lives on the key's own leaf decor, one above a
453                // sub-table's header on the table's own decor, and a trailing
454                // one on the value's suffix.
455                let prefix = table.get_key_value(key).map(|(held, item)| {
456                    let mut text = String::new();
457                    if let Item::Table(inner) = item
458                        && let Some(raw) = inner.decor().prefix().and_then(|raw| raw.as_str())
459                    {
460                        text.push_str(raw);
461                    }
462                    text.push_str(&key_text(held));
463                    let suffix = item_suffix(item);
464                    if !suffix.trim().is_empty() {
465                        if !text.is_empty() && !text.ends_with('\n') {
466                            text.push('\n');
467                        }
468                        text.push_str(suffix.trim_start());
469                        if !text.ends_with('\n') {
470                            text.push('\n');
471                        }
472                    }
473                    text
474                });
475                let removed = table.remove(key);
476                let prefix = prefix?;
477                if removed.is_none() || prefix.trim().is_empty() {
478                    return None;
479                }
480                if let Some((mut next, _)) = next.and_then(|next| table.get_key_value_mut(&next)) {
481                    let existing = next
482                        .leaf_decor()
483                        .prefix()
484                        .and_then(|raw| raw.as_str())
485                        .map(str::to_owned)
486                        .unwrap_or_default();
487                    next.leaf_decor_mut().set_prefix(format!("{prefix}{existing}"));
488                    return None;
489                }
490                Some(prefix)
491            }
492            Self::Inline(table) => {
493                table.remove(key);
494                None
495            }
496        }
497    }
498}
499
500/// The key after `key` in the table's own order, before it is removed.
501fn next_key_after(table: &Table, key: &str) -> Option<String> {
502    let mut seen = false;
503    for (candidate, _) in table {
504        if seen {
505            return Some(candidate.to_owned());
506        }
507        if candidate == key {
508            seen = true;
509        }
510    }
511    None
512}
513
514/// The comments and whitespace a table entry's key carries before it.
515fn key_text(key: &toml_edit::Key) -> String {
516    key.leaf_decor().prefix().and_then(|raw| raw.as_str()).map(str::to_owned).unwrap_or_default()
517}
518
519/// The trailing comment a value carries on its own line.
520fn item_suffix(item: &Item) -> String {
521    match item {
522        Item::Value(value) => value
523            .decor()
524            .suffix()
525            .and_then(|raw| raw.as_str())
526            .map(str::to_owned)
527            .unwrap_or_default(),
528        _ => String::new(),
529    }
530}
531
532/// Remove one table from an array of tables, moving its comments onto the
533/// entry that follows (or the previous entry's trailing text when it was
534/// last) so a removal does not take the reader's notes with it. A prefix
535/// with nowhere to go - the array's only entry - is handed back for the
536/// parent table to carry.
537fn remove_entry_at(array: &mut ArrayOfTables, index: usize) -> Option<String> {
538    let prefix = array
539        .get(index)
540        .and_then(|table| table.decor().prefix().and_then(|raw| raw.as_str()).map(str::to_owned));
541    let Some(prefix) = prefix.filter(|text| !text.trim().is_empty()) else {
542        array.remove(index);
543        return None;
544    };
545    array.remove(index);
546    if let Some(next) = array.get_mut(index) {
547        let existing = next
548            .decor()
549            .prefix()
550            .and_then(|raw| raw.as_str())
551            .map(str::to_owned)
552            .unwrap_or_default();
553        next.decor_mut().set_prefix(format!("{prefix}{existing}"));
554        return None;
555    }
556    if index > 0
557        && let Some(previous) = array.get_mut(index - 1)
558    {
559        let existing = previous
560            .decor()
561            .suffix()
562            .and_then(|raw| raw.as_str())
563            .map(str::to_owned)
564            .unwrap_or_default();
565        previous.decor_mut().set_suffix(format!("{existing}{prefix}"));
566        return None;
567    }
568    Some(prefix)
569}
570
571/// Put a leftover entry's comments at the end of the table that held the
572/// array, so an only-entry removal keeps them.
573fn keep_prefix(table: &mut Table, prefix: Option<String>) {
574    if let Some(prefix) = prefix {
575        keep_suffix(table, &prefix);
576    }
577}
578
579/// Append text to a table's own trailing decor.
580fn keep_suffix(table: &mut Table, prefix: &str) {
581    let existing =
582        table.decor().suffix().and_then(|raw| raw.as_str()).map(str::to_owned).unwrap_or_default();
583    table.decor_mut().set_suffix(format!("{existing}{prefix}"));
584}
585
586/// Put a departed table's comments above the next root entry, or at the
587/// document's end when it was the last one.
588fn place_root_prefix(doc: &mut DocumentMut, next: Option<String>, prefix: &str) {
589    if prefix.trim().is_empty() {
590        return;
591    }
592    if let Some(next) = next
593        && let Some((mut key, _)) = doc.as_table_mut().get_key_value_mut(&next)
594    {
595        let existing = key
596            .leaf_decor()
597            .prefix()
598            .and_then(|raw| raw.as_str())
599            .map(str::to_owned)
600            .unwrap_or_default();
601        key.leaf_decor_mut().set_prefix(format!("{prefix}{existing}"));
602        return;
603    }
604    let root = doc.as_table_mut();
605    let existing =
606        root.decor().suffix().and_then(|raw| raw.as_str()).map(str::to_owned).unwrap_or_default();
607    root.decor_mut().set_suffix(format!("{existing}{prefix}"));
608}
609
610fn set_item_value(item: &mut Item, value: Value) {
611    match item {
612        Item::Value(existing) => {
613            let decor = existing.decor().clone();
614            let mut value = value;
615            *value.decor_mut() = decor;
616            *existing = value;
617        }
618        _ => *item = Item::Value(value),
619    }
620}
621
622fn descend<'a>(doc: &'a mut DocumentMut, steps: &[Step], create: bool) -> Option<Container<'a>> {
623    descend_from(Container::Table(doc.as_table_mut()), steps, create)
624}
625
626fn descend_from<'a>(
627    container: Container<'a>,
628    steps: &[Step],
629    create: bool,
630) -> Option<Container<'a>> {
631    let Some((step, rest)) = steps.split_first() else { return Some(container) };
632    let next: Container<'a> = match step {
633        Step::Key(key) => match container {
634            Container::Table(table) => {
635                if create && table.get(key).is_none() {
636                    table.insert(key, Item::Table(Table::new()));
637                }
638                match table.get_mut(key)? {
639                    Item::Table(table) => Container::Table(table),
640                    Item::Value(Value::InlineTable(table)) => Container::Inline(table),
641                    _ => return None,
642                }
643            }
644            Container::Inline(_) => return None,
645        },
646        Step::Account(name) => {
647            let Container::Table(root) = container else { return None };
648            let array = root.get_mut("accounts")?.as_array_of_tables_mut()?;
649            let index = array
650                .iter()
651                .position(|table| table.get("display_name").and_then(Item::as_str) == Some(name))?;
652            Container::Table(array.get_mut(index)?)
653        }
654        Step::Org(name) => {
655            let Container::Table(root) = container else { return None };
656            let array = root.get_mut("orgs")?.as_array_of_tables_mut()?;
657            let index = array
658                .iter()
659                .position(|table| table.get("name").and_then(Item::as_str) == Some(name))?;
660            Container::Table(array.get_mut(index)?)
661        }
662        Step::Slack(name) => {
663            let Container::Table(root) = container else { return None };
664            let array = root.get_mut("slack")?.as_array_of_tables_mut()?;
665            let index = array
666                .iter()
667                .position(|table| table.get("workspace").and_then(Item::as_str) == Some(name))?;
668            Container::Table(array.get_mut(index)?)
669        }
670        Step::Project(name) => {
671            let Container::Table(org) = container else { return None };
672            let array = org.get_mut("projects")?.as_array_of_tables_mut()?;
673            let index = array
674                .iter()
675                .position(|table| table.get("name").and_then(Item::as_str) == Some(name))?;
676            Container::Table(array.get_mut(index)?)
677        }
678    };
679    descend_from(next, rest, create)
680}
681
682/// Validate one value against a key's edit spec without applying it, so a
683/// tool can refuse a wrong type before it asks the reader anything.
684pub(crate) fn validate(
685    value: &serde_json::Value,
686    edit: &SettingsEdit,
687    path: &str,
688) -> Result<(), WriteError> {
689    to_toml(value, edit.kind, &edit.allowed, path).map(|_| ())
690}
691
692/// Convert a JSON value to the TOML shape its edit kind names.
693fn to_toml(
694    value: &serde_json::Value,
695    kind: EditKind,
696    allowed: &[String],
697    path: &str,
698) -> Result<Value, WriteError> {
699    let bad = |what: &str| invalid(format!("`{path}` wants {what}"), Some(path.to_owned()));
700    match kind {
701        EditKind::String => {
702            let value = value.as_str().ok_or_else(|| bad("a string"))?;
703            Ok(Value::from(value.to_owned()))
704        }
705        EditKind::Enum => {
706            let value = value.as_str().ok_or_else(|| bad("one of its names"))?;
707            if !allowed.iter().any(|name| name == value) {
708                return Err(invalid(
709                    format!("`{path}` accepts {}; `{value}` is not one", allowed.join(", ")),
710                    Some(path.to_owned()),
711                ));
712            }
713            Ok(Value::from(value.to_owned()))
714        }
715        EditKind::Number => {
716            if let Some(number) = value.as_i64() {
717                Ok(Value::from(number))
718            } else if let Some(number) = value.as_u64() {
719                i64::try_from(number).map(Value::from).map_err(|_| bad("a whole number"))
720            } else {
721                Err(bad("a whole number"))
722            }
723        }
724        EditKind::Bool => {
725            let value = value.as_bool().ok_or_else(|| bad("true or false"))?;
726            Ok(Value::from(value))
727        }
728        EditKind::List => {
729            let items = value.as_array().ok_or_else(|| bad("a list"))?;
730            let mut array = Array::new();
731            for item in items {
732                let text = item.as_str().ok_or_else(|| bad("a list of strings"))?;
733                array.push(Value::from(text.to_owned()));
734            }
735            Ok(Value::Array(array))
736        }
737    }
738}
739
740/// Apply an entry's fields to a fresh table.
741fn entry_table(
742    value: &serde_json::Value,
743    name_field: Option<&str>,
744    name: &str,
745) -> Result<Table, WriteError> {
746    let object = value
747        .as_object()
748        .ok_or_else(|| invalid("an entry is added with its fields", Some(name.to_owned())))?;
749    let mut table = Table::new();
750    for (key, held) in object {
751        let Some(item) = json_item(held, key)? else { continue };
752        table.insert(key, item);
753    }
754    if let Some(field) = name_field
755        && table.get(field).is_none()
756    {
757        table.insert(field, Item::Value(Value::from(name.to_owned())));
758    }
759    Ok(table)
760}
761
762fn json_item(value: &serde_json::Value, path: &str) -> Result<Option<Item>, WriteError> {
763    match value {
764        serde_json::Value::Null => Ok(None),
765        serde_json::Value::Bool(value) => Ok(Some(Item::Value(Value::from(*value)))),
766        serde_json::Value::Number(number) => {
767            if let Some(value) = number.as_i64() {
768                Ok(Some(Item::Value(Value::from(value))))
769            } else if let Some(value) = number.as_u64() {
770                i64::try_from(value).map(|value| Some(Item::Value(Value::from(value)))).map_err(
771                    |_| invalid(format!("`{path}` wants a whole number"), Some(path.to_owned())),
772                )
773            } else {
774                Err(invalid(format!("`{path}` wants a whole number"), Some(path.to_owned())))
775            }
776        }
777        serde_json::Value::String(value) => Ok(Some(Item::Value(Value::from(value.clone())))),
778        serde_json::Value::Array(items) => {
779            let mut array = Array::new();
780            for item in items {
781                let text = item.as_str().ok_or_else(|| {
782                    invalid(format!("`{path}` wants a list of strings"), Some(path.to_owned()))
783                })?;
784                array.push(Value::from(text.to_owned()));
785            }
786            Ok(Some(Item::Value(Value::Array(array))))
787        }
788        serde_json::Value::Object(fields) => {
789            let mut table = Table::new();
790            for (key, held) in fields {
791                if let Some(item) = json_item(held, key)? {
792                    table.insert(key, item);
793                }
794            }
795            Ok(Some(Item::Table(table)))
796        }
797    }
798}
799
800/// Add a whole entry: an account, a project under an org, a Slack workspace,
801/// or a model table.
802fn add_entry(doc: &mut DocumentMut, edit: &SettingsEditRequest) -> Result<(), WriteError> {
803    let value = edit
804        .value
805        .as_ref()
806        .ok_or_else(|| invalid("an entry is added with its fields", Some(edit.key_path.clone())))?;
807    match edit.area.as_str() {
808        "accounts" => {
809            let name = strip_prefix(&edit.key_path, "accounts.").ok_or_else(|| {
810                invalid("an account's path names the account", Some(edit.key_path.clone()))
811            })?;
812            let table = entry_table(value, Some("display_name"), name)?;
813            let exists =
814                doc.as_table().get("accounts").and_then(Item::as_array_of_tables).is_some_and(
815                    |array| {
816                        array.iter().any(|held| {
817                            held.get("display_name").and_then(Item::as_str) == Some(name)
818                        })
819                    },
820                );
821            if exists {
822                return Err(invalid(
823                    format!("an account named `{name}` already exists"),
824                    Some(edit.key_path.clone()),
825                ));
826            }
827            let accounts = doc
828                .as_table_mut()
829                .entry("accounts")
830                .or_insert(Item::ArrayOfTables(ArrayOfTables::default()));
831            let array = accounts.as_array_of_tables_mut().ok_or_else(|| {
832                invalid("`accounts` is not an array of tables", Some(edit.key_path.clone()))
833            })?;
834            array.push(table);
835            Ok(())
836        }
837        "connectors" => {
838            let name = strip_prefix(&edit.key_path, "slack.").ok_or_else(|| {
839                invalid("a Slack entry's path names the workspace", Some(edit.key_path.clone()))
840            })?;
841            let table = entry_table(value, Some("workspace"), name)?;
842            // **The entry's identity is the workspace its value names**, not
843            // the path's tail: entry_table keeps a value that carries its own
844            // `workspace`, and the exists check has to compare the same name
845            // the file will hold.
846            let identity = value.get("workspace").and_then(|held| held.as_str()).unwrap_or(name);
847            let exists =
848                doc.as_table().get("slack").and_then(Item::as_array_of_tables).is_some_and(
849                    |array| {
850                        array.iter().any(|held| {
851                            held.get("workspace").and_then(Item::as_str) == Some(identity)
852                        })
853                    },
854                );
855            if exists {
856                return Err(invalid(
857                    format!("a Slack workspace named `{identity}` already exists"),
858                    Some(edit.key_path.clone()),
859                ));
860            }
861            let slack = doc
862                .as_table_mut()
863                .entry("slack")
864                .or_insert(Item::ArrayOfTables(ArrayOfTables::default()));
865            let array = slack.as_array_of_tables_mut().ok_or_else(|| {
866                invalid("`slack` is not an array of tables", Some(edit.key_path.clone()))
867            })?;
868            array.push(table);
869            Ok(())
870        }
871        "models" => {
872            let name = strip_prefix(&edit.key_path, "models.").ok_or_else(|| {
873                invalid("a model's path names the model", Some(edit.key_path.clone()))
874            })?;
875            let table = entry_table(value, None, name)?;
876            let exists = doc
877                .as_table()
878                .get("models")
879                .and_then(Item::as_table)
880                .is_some_and(|models| models.contains_key(name));
881            if exists {
882                return Err(invalid(
883                    format!("a model named `{name}` already exists"),
884                    Some(edit.key_path.clone()),
885                ));
886            }
887            let models = doc.as_table_mut().entry("models").or_insert(Item::Table(Table::new()));
888            let models = models
889                .as_table_mut()
890                .ok_or_else(|| invalid("`models` is not a table", Some(edit.key_path.clone())))?;
891            models.insert(name, Item::Table(table));
892            Ok(())
893        }
894        "projects" => {
895            let Some((org, project)) = project_path(doc, &edit.key_path) else {
896                return Err(invalid(
897                    "a project's path names its org and the project",
898                    Some(edit.key_path.clone()),
899                ));
900            };
901            let table = entry_table(value, Some("name"), &project)?;
902            let orgs = doc
903                .as_table_mut()
904                .get_mut("orgs")
905                .ok_or_else(|| invalid("this config has no orgs", Some(edit.key_path.clone())))?;
906            let Item::ArrayOfTables(orgs) = orgs else {
907                return Err(invalid(
908                    "`orgs` is not an array of tables",
909                    Some(edit.key_path.clone()),
910                ));
911            };
912            let index = orgs
913                .iter()
914                .position(|table| table.get("name").and_then(Item::as_str) == Some(org.as_str()))
915                .ok_or_else(|| {
916                    invalid(format!("no org named `{org}`"), Some(edit.key_path.clone()))
917                })?;
918            let org_table = orgs
919                .get_mut(index)
920                .ok_or_else(|| invalid("the org went away", Some(edit.key_path.clone())))?;
921            let projects = org_table
922                .entry("projects")
923                .or_insert(Item::ArrayOfTables(ArrayOfTables::default()));
924            let array = projects.as_array_of_tables_mut().ok_or_else(|| {
925                invalid("`projects` is not an array of tables", Some(edit.key_path.clone()))
926            })?;
927            let exists = array
928                .iter()
929                .any(|held| held.get("name").and_then(Item::as_str) == Some(project.as_str()));
930            if exists {
931                return Err(invalid(
932                    format!("a project named `{project}` already exists under `{org}`"),
933                    Some(edit.key_path.clone()),
934                ));
935            }
936            array.push(table);
937            Ok(())
938        }
939        area => {
940            Err(invalid(format!("`{area}` does not add entries here"), Some(edit.key_path.clone())))
941        }
942    }
943}
944
945/// Remove a whole entry.
946fn remove_entry(doc: &mut DocumentMut, edit: &SettingsEditRequest) -> Result<(), WriteError> {
947    let not_found = || invalid("that entry is not in this file", Some(edit.key_path.clone()));
948    match edit.area.as_str() {
949        "accounts" => {
950            let name = strip_prefix(&edit.key_path, "accounts.").ok_or_else(not_found)?;
951            let accounts = doc
952                .as_table_mut()
953                .get_mut("accounts")
954                .and_then(Item::as_array_of_tables_mut)
955                .ok_or_else(not_found)?;
956            let index = accounts
957                .iter()
958                .position(|table| table.get("display_name").and_then(Item::as_str) == Some(name))
959                .ok_or_else(not_found)?;
960            let left = remove_entry_at(accounts, index);
961            keep_prefix(doc.as_table_mut(), left);
962            Ok(())
963        }
964        "connectors" => {
965            let name = strip_prefix(&edit.key_path, "slack.").ok_or_else(not_found)?;
966            let slack = doc
967                .as_table_mut()
968                .get_mut("slack")
969                .and_then(Item::as_array_of_tables_mut)
970                .ok_or_else(not_found)?;
971            let index = slack
972                .iter()
973                .position(|table| table.get("workspace").and_then(Item::as_str) == Some(name))
974                .ok_or_else(not_found)?;
975            let left = remove_entry_at(slack, index);
976            keep_prefix(doc.as_table_mut(), left);
977            Ok(())
978        }
979        "models" => {
980            let name = strip_prefix(&edit.key_path, "models.").ok_or_else(not_found)?;
981            let models = doc
982                .as_table_mut()
983                .get_mut("models")
984                .and_then(Item::as_table_mut)
985                .ok_or_else(not_found)?;
986            // The comment above `[models.foo]` lives on that sub-table's own
987            // decor, so the removal moves it like any other key's.
988            let removed_comments = Container::Table(models).remove(name);
989            let emptied =
990                doc.as_table().get("models").and_then(Item::as_table).is_some_and(Table::is_empty);
991            let next = next_key_after(doc.as_table(), "models");
992            let mut prefix = removed_comments.unwrap_or_default();
993            if emptied {
994                // `[models]` may be an explicit header: its own comment lives
995                // on the root key and the table's decor, and removing the
996                // emptied key would take it.
997                if let Some((key, item)) = doc.as_table().get_key_value("models") {
998                    prefix.push_str(&key_text(key));
999                    if let Item::Table(table) = item
1000                        && let Some(raw) = table.decor().prefix().and_then(|raw| raw.as_str())
1001                    {
1002                        prefix.push_str(raw);
1003                    }
1004                }
1005                doc.as_table_mut().remove("models");
1006            }
1007            // An implicit `[models]` never renders its suffix, so anything
1008            // freed - the last model's comment, or the header's - moves to
1009            // the next root entry (or the document's own end).
1010            place_root_prefix(doc, next, &prefix);
1011            Ok(())
1012        }
1013        "projects" => {
1014            let (org, project) = project_path(doc, &edit.key_path).ok_or_else(not_found)?;
1015            let orgs = doc
1016                .as_table_mut()
1017                .get_mut("orgs")
1018                .and_then(Item::as_array_of_tables_mut)
1019                .ok_or_else(not_found)?;
1020            let org_index = orgs
1021                .iter()
1022                .position(|table| table.get("name").and_then(Item::as_str) == Some(org.as_str()))
1023                .ok_or_else(not_found)?;
1024            let org_table = orgs.get_mut(org_index).ok_or_else(not_found)?;
1025            let projects = org_table
1026                .get_mut("projects")
1027                .and_then(Item::as_array_of_tables_mut)
1028                .ok_or_else(not_found)?;
1029            let index = projects
1030                .iter()
1031                .position(|table| {
1032                    table.get("name").and_then(Item::as_str) == Some(project.as_str())
1033                })
1034                .ok_or_else(not_found)?;
1035            let left = remove_entry_at(projects, index);
1036            if let Some(org_table) = doc
1037                .as_table_mut()
1038                .get_mut("orgs")
1039                .and_then(Item::as_array_of_tables_mut)
1040                .and_then(|orgs| orgs.get_mut(org_index))
1041            {
1042                keep_prefix(org_table, left);
1043            }
1044            Ok(())
1045        }
1046        _ => Err(not_found()),
1047    }
1048}
1049
1050fn strip_prefix<'a>(path: &'a str, prefix: &str) -> Option<&'a str> {
1051    path.strip_prefix(prefix)
1052}
1053
1054/// The org and project a project entry path names, matched against the
1055/// document's own org names: an org name containing dots (or the literal
1056/// `.projects.`) cannot be cut short by a split.
1057fn project_path(doc: &DocumentMut, path: &str) -> Option<(String, String)> {
1058    let rest = path.strip_prefix("orgs.")?;
1059    let orgs = doc.as_table().get("orgs")?.as_array_of_tables()?;
1060    // **An exact identity comes first.** A project named `Team.projects.api`
1061    // under org `Acme` and a project `api` under org `Acme.projects.Team`
1062    // spell the same path, so the document's own pairs decide before any
1063    // cut - only an entry that does not exist yet falls through to the
1064    // longest org the path parses against.
1065    for org_table in orgs {
1066        let Some(org) = org_table.get("name").and_then(Item::as_str) else { continue };
1067        let Some(projects) = org_table.get("projects").and_then(Item::as_array_of_tables) else {
1068            continue;
1069        };
1070        for project_table in projects {
1071            let Some(project) = project_table.get("name").and_then(Item::as_str) else { continue };
1072            if path == format!("orgs.{org}.projects.{project}") {
1073                return Some((org.to_owned(), project.to_owned()));
1074            }
1075        }
1076    }
1077    let mut best: Option<(String, String)> = None;
1078    for table in orgs {
1079        let Some(org) = table.get("name").and_then(Item::as_str) else { continue };
1080        let Some(tail) = rest.strip_prefix(org).and_then(|tail| tail.strip_prefix(".projects."))
1081        else {
1082            continue;
1083        };
1084        if best.as_ref().is_none_or(|(held, _)| org.len() > held.len()) {
1085            best = Some((org.to_owned(), tail.to_owned()));
1086        }
1087    }
1088    best
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use std::path::Path;
1094
1095    use forge_primitives::settings::{SettingsDocument, SettingsEditOp, SettingsEditRequest};
1096
1097    use super::*;
1098
1099    /// A config with comments, two accounts, an org and its projects, and a
1100    /// model table, so an edit can be read against the text around it.
1101    const FIXTURE: &str = r#"# forge.toml - the fixture
1102[server]
1103# the socket's port
1104port = 8790  # a hand-written comment
1105bind = "127.0.0.1"
1106
1107[models."deepseek-v4.1-flash"]
1108auto_compact_window = 983040
1109
1110# the first account
1111[[accounts]]
1112display_name = "One"
1113provider = "anthropic"
1114token = "one-token"
1115models = ["deepseek-v4.1-flash"]
1116model_slugs = { "deepseek-v4.1-flash" = "deepseek/deepseek-v4.1-flash" }
1117
1118[accounts.env]
1119FLAG = "1"
1120
1121# the second account
1122[[accounts]]
1123display_name = "Two"
1124provider = "anthropic"
1125token = "two-token"
1126models = ["deepseek-v4.1-flash"]
1127
1128[[orgs]]
1129name = "Acme.Corp"
1130accounts = ["One"]
1131
1132[[orgs.projects]]
1133name = "api.v2"
1134path = "/tmp/api.v2"
1135model = "deepseek-v4.1-flash"
1136permission_mode = "auto"
1137
1138[orgs.projects.env]
1139SECRET = "s"
1140
1141[[orgs.projects]]
1142name = "site"
1143path = "/tmp/site"
1144model = "deepseek-v4.1-flash"
1145
1146[[slack]]
1147workspace = "Work"
1148token = "xoxp-example"
1149"#;
1150
1151    fn document(raw: &str) -> SettingsDocument {
1152        let config =
1153            crate::config::load_from_text(std::path::PathBuf::from("/tmp/forge.toml"), raw)
1154                .expect("the fixture loads");
1155        crate::settings::document(&config, "v", Path::new("/tmp/forge.toml"))
1156    }
1157
1158    fn key(document: &SettingsDocument, path: &str) -> (String, String) {
1159        let found = document
1160            .areas
1161            .iter()
1162            .flat_map(|area| &area.keys)
1163            .find(|key| key.path == path)
1164            .unwrap_or_else(|| panic!("{path} has no row"));
1165        (found.pattern.clone(), found.path.clone())
1166    }
1167
1168    fn set_edit(
1169        document: &SettingsDocument,
1170        path: &str,
1171        value: serde_json::Value,
1172    ) -> SettingsEditRequest {
1173        let (pattern, key_path) = key(document, path);
1174        let area = document
1175            .areas
1176            .iter()
1177            .find(|area| area.keys.iter().any(|key| key.path == path))
1178            .map(|area| area.id.clone())
1179            .expect("the key has an area");
1180        SettingsEditRequest { area, key_path, pattern, op: SettingsEditOp::Set, value: Some(value) }
1181    }
1182
1183    fn entry_edit(
1184        area: &str,
1185        key_path: &str,
1186        op: SettingsEditOp,
1187        value: Option<serde_json::Value>,
1188    ) -> SettingsEditRequest {
1189        SettingsEditRequest {
1190            area: area.to_owned(),
1191            key_path: key_path.to_owned(),
1192            pattern: String::new(),
1193            op,
1194            value,
1195        }
1196    }
1197
1198    /// The key's value is replaced, and every comment and every line that
1199    /// did not change stays exactly where it was.
1200    #[test]
1201    fn a_set_preserves_the_comments_and_the_order() {
1202        let raw = FIXTURE;
1203        let document = document(raw);
1204        let edit = set_edit(&document, "server.port", serde_json::json!(8791));
1205        let out = apply(raw, &[edit], &document).expect("the edit applies");
1206        assert!(out.contains("# forge.toml - the fixture"), "the top comment stayed: {out}");
1207        assert!(out.contains("# the socket's port"), "the key's comment stayed: {out}");
1208        assert!(out.contains("# a hand-written comment"), "the inline comment stayed: {out}");
1209        assert!(out.contains("port = 8791"), "the value moved: {out}");
1210        assert!(
1211            out.find("[server]") < out.find("[[accounts]]"),
1212            "the tables kept their order: {out}",
1213        );
1214    }
1215
1216    /// A value of the wrong shape is refused before anything else is tried.
1217    #[test]
1218    fn a_string_is_refused_for_a_number() {
1219        let document = document(FIXTURE);
1220        let edit = set_edit(&document, "server.port", serde_json::json!("not a number"));
1221        let error = apply(FIXTURE, &[edit], &document).expect_err("a string is not a port");
1222        assert!(error.message.contains("want"), "the message names the shape: {error:?}");
1223        assert_eq!(error.key_path.as_deref(), Some("server.port"));
1224    }
1225
1226    /// **A batch naming one key twice is refused whole.** Two edits for one
1227    /// path have no defined winner, and the second would silently undo the
1228    /// first while the save still reported a change.
1229    #[test]
1230    fn a_batch_naming_one_key_twice_is_refused() {
1231        let document = document(FIXTURE);
1232        let first = set_edit(&document, "server.port", serde_json::json!(8791));
1233        let second = set_edit(&document, "server.port", serde_json::json!(8792));
1234
1235        let error =
1236            apply(FIXTURE, &[first, second], &document).expect_err("the duplicate is refused");
1237
1238        assert!(error.message.contains("server.port"), "{}", error.message);
1239        assert!(error.message.contains("twice"), "{}", error.message);
1240        assert_eq!(error.key_path.as_deref(), Some("server.port"));
1241    }
1242
1243    /// A project whose org and name carry dots is found by its pattern, not
1244    /// by splitting the display path on every dot.
1245    #[test]
1246    fn a_dotted_project_name_is_located_by_its_pattern() {
1247        let document = document(FIXTURE);
1248        let edit = set_edit(
1249            &document,
1250            "orgs.Acme.Corp.projects.api.v2.model",
1251            serde_json::json!("deepseek-v4.1-flash"),
1252        );
1253        let out = apply(FIXTURE, &[edit], &document).expect("the dotted project applies");
1254        assert!(out.contains("[[orgs.projects]]\nname = \"api.v2\""), "{out}");
1255        assert!(out.contains("model = \"deepseek-v4.1-flash\""), "{out}");
1256    }
1257
1258    /// An inline table's entry is rewritten in place.
1259    #[test]
1260    fn an_inline_table_entry_is_set() {
1261        let document = document(FIXTURE);
1262        let edit = set_edit(
1263            &document,
1264            "accounts.One.model_slugs.deepseek-v4.1-flash",
1265            serde_json::json!("other/flash"),
1266        );
1267        let out = apply(FIXTURE, &[edit], &document).expect("the slug applies");
1268        assert!(out.contains("\"other/flash\""), "the slug moved: {out}");
1269        assert!(out.contains("display_name = \"One\""), "the account stayed: {out}");
1270    }
1271
1272    /// An optional key removed from the file is gone, and its neighbours are
1273    /// untouched.
1274    #[test]
1275    fn a_leaf_removal_takes_only_that_key() {
1276        let document = document(FIXTURE);
1277        let (pattern, key_path) = key(&document, "accounts.One.env.FLAG");
1278        let edit = SettingsEditRequest {
1279            area: "accounts".to_owned(),
1280            key_path,
1281            pattern,
1282            op: SettingsEditOp::Remove,
1283            value: None,
1284        };
1285        let out = apply(FIXTURE, &[edit], &document).expect("the removal applies");
1286        assert!(!out.contains("FLAG = \"1\""), "the key went: {out}");
1287        assert!(out.contains("[accounts.env]"), "the empty table stays: {out}");
1288    }
1289
1290    /// A new project is added under its org with the fields the page sent.
1291    #[test]
1292    fn a_project_is_added_under_its_org() {
1293        let document = document(FIXTURE);
1294        let edit = entry_edit(
1295            "projects",
1296            "orgs.Acme.Corp.projects.new-one",
1297            SettingsEditOp::Add,
1298            Some(serde_json::json!({
1299                "name": "new-one",
1300                "path": "/tmp/new-one",
1301                "model": "deepseek-v4.1-flash",
1302            })),
1303        );
1304        let out = apply(FIXTURE, &[edit], &document).expect("the project adds");
1305        assert!(out.contains("name = \"new-one\""), "the project is in the text: {out}");
1306        assert!(out.contains("path = \"/tmp/new-one\""), "{out}");
1307    }
1308
1309    /// An account is removed whole.
1310    #[test]
1311    fn an_account_is_removed_whole() {
1312        let document = document(FIXTURE);
1313        let edit = entry_edit("accounts", "accounts.Two", SettingsEditOp::Remove, None);
1314        let out = apply(FIXTURE, &[edit], &document).expect("the account removes");
1315        assert!(!out.contains("\"Two\""), "the account went: {out}");
1316        assert!(out.contains("display_name = \"One\""), "the other stayed: {out}");
1317    }
1318
1319    /// A model table is added and removed by name.
1320    #[test]
1321    fn a_model_table_is_added_and_removed() {
1322        let document = document(FIXTURE);
1323        let add = entry_edit(
1324            "models",
1325            "models.new-model",
1326            SettingsEditOp::Add,
1327            Some(serde_json::json!({ "auto_compact_window": 1024 })),
1328        );
1329        let out = apply(FIXTURE, &[add], &document).expect("the model adds");
1330        assert!(out.contains("[models.new-model]"), "{out}");
1331        let remove = entry_edit("models", "models.new-model", SettingsEditOp::Remove, None);
1332        let out = apply(&out, &[remove], &document).expect("the model removes");
1333        assert!(!out.contains("[models.new-model]"), "{out}");
1334        assert!(out.contains("[models.\"deepseek-v4.1-flash\"]"), "the other stayed: {out}");
1335    }
1336
1337    /// A Slack workspace's pump token changes in place.
1338    #[test]
1339    fn a_slack_token_is_set() {
1340        let document = document(FIXTURE);
1341        let edit = set_edit(&document, "slack.Work.token", serde_json::json!("xoxp-new"));
1342        let out = apply(FIXTURE, &[edit], &document).expect("the token applies");
1343        assert!(out.contains("xoxp-new"), "{out}");
1344        assert!(out.contains("workspace = \"Work\""), "{out}");
1345    }
1346
1347    /// **A removal keeps the comments above the key.** The port carries a
1348    /// hand-written note; removing the key must move the note, not delete it.
1349    #[test]
1350    fn a_key_removal_keeps_its_comments() {
1351        let document = document(FIXTURE);
1352        let (pattern, key_path) = key(&document, "server.port");
1353        let edit = SettingsEditRequest {
1354            area: "server".to_owned(),
1355            key_path,
1356            pattern,
1357            op: SettingsEditOp::Remove,
1358            value: None,
1359        };
1360        let out = apply(FIXTURE, &[edit], &document).expect("the key removes");
1361        assert!(!out.contains("port = 8790"), "the key is gone: {out}");
1362        assert!(out.contains("# the socket's port"), "the note above it stayed: {out}");
1363    }
1364
1365    /// **A table's last key takes its comment to the table's end.** Removing
1366    /// it has no following key to carry the note.
1367    #[test]
1368    fn a_last_key_removal_keeps_its_comments() {
1369        let raw =
1370            FIXTURE.replace("bind = \"127.0.0.1\"", "# the bind address\nbind = \"127.0.0.1\"");
1371        let document = document(&raw);
1372        let (pattern, key_path) = key(&document, "server.bind");
1373        let edit = SettingsEditRequest {
1374            area: "server".to_owned(),
1375            key_path,
1376            pattern,
1377            op: SettingsEditOp::Remove,
1378            value: None,
1379        };
1380        let out = apply(&raw, &[edit], &document).expect("the key removes");
1381        assert!(!out.contains("bind = "), "the key is gone: {out}");
1382        assert!(out.contains("# the bind address"), "its note stayed: {out}");
1383    }
1384
1385    /// **A removal keeps the comments above the entry** it takes.
1386    #[test]
1387    fn an_entry_removal_keeps_its_comments() {
1388        let document = document(FIXTURE);
1389        let edit = entry_edit("accounts", "accounts.One", SettingsEditOp::Remove, None);
1390        let out = apply(FIXTURE, &[edit], &document).expect("the account removes");
1391        assert!(!out.contains("display_name = \"One\""), "the entry is gone: {out}");
1392        assert!(out.contains("# the first account"), "its note stayed: {out}");
1393    }
1394
1395    /// **A CRLF config keeps its line endings.** toml_edit renders `\n`; the
1396    /// writer must hand back the file's own style.
1397    #[test]
1398    fn a_crlf_file_keeps_its_line_endings() {
1399        let raw = FIXTURE.replace('\n', "\r\n");
1400        let document = document(&raw);
1401        let edit = set_edit(&document, "server.port", serde_json::json!(8791));
1402        let out = apply(&raw, &[edit], &document).expect("the edit applies");
1403        assert!(out.contains("\r\n"), "still CRLF: {out:?}");
1404        assert!(!out.replace("\r\n", "").contains('\n'), "no bare LF was introduced: {out:?}");
1405    }
1406
1407    /// **A sibling whose name continues the path is not the target.**
1408    /// Projects `api` and `api.handoff` both match the row for `api`'s own
1409    /// handoff table, and the row's group names the longer one.
1410    #[test]
1411    fn a_sibling_named_like_the_next_segment_is_not_the_target() {
1412        let raw = r#"
1413[models."m"]
1414auto_compact_window = 1
1415
1416[[accounts]]
1417display_name = "One"
1418provider = "anthropic"
1419token = "t"
1420models = ["m"]
1421
1422[[orgs]]
1423name = "Acme"
1424accounts = ["One"]
1425
1426[[orgs.projects]]
1427name = "api"
1428path = "/tmp/api"
1429model = "m"
1430
1431[orgs.projects.handoff]
1432enabled = true
1433
1434[[orgs.projects]]
1435name = "api.handoff"
1436path = "/tmp/api.handoff"
1437model = "m"
1438
1439[orgs.projects.handoff]
1440enabled = true
1441"#;
1442        let document = document(raw);
1443        let edit =
1444            set_edit(&document, "orgs.Acme.projects.api.handoff.enabled", serde_json::json!(false));
1445        let out = apply(raw, &[edit], &document).expect("the edit applies");
1446        // **The false is `api`'s**, not merely one of the two: the sibling's
1447        // table must still read true.
1448        let mut blocks = out.split("[[orgs.projects]]");
1449        blocks.next();
1450        let api_block = blocks.next().unwrap_or_default();
1451        let sibling_block = blocks.next().unwrap_or_default();
1452        assert!(api_block.contains("name = \"api\""), "the first block is api: {out}");
1453        assert!(api_block.contains("enabled = false"), "api's table took the set: {out}");
1454        assert!(
1455            sibling_block.contains("name = \"api.handoff\""),
1456            "the second is the sibling: {out}"
1457        );
1458        assert!(sibling_block.contains("enabled = true"), "the sibling kept its value: {out}");
1459    }
1460
1461    /// **The last model's comment survives its removal.** An implicit
1462    /// `[models]` never renders a suffix, so the note moves above the next
1463    /// root entry.
1464    #[test]
1465    fn a_last_model_removal_keeps_its_comments() {
1466        let raw = r#"
1467[models."a"]
1468auto_compact_window = 1
1469
1470# the bee model
1471[models."b"]
1472auto_compact_window = 2
1473
1474[[accounts]]
1475display_name = "One"
1476provider = "anthropic"
1477token = "t"
1478models = ["a"]
1479
1480[[orgs]]
1481name = "Acme"
1482accounts = ["One"]
1483
1484[[orgs.projects]]
1485name = "api"
1486path = "/tmp/api"
1487model = "a"
1488"#;
1489        let document = document(raw);
1490        let edit = entry_edit("models", "models.b", SettingsEditOp::Remove, None);
1491        let out = apply(raw, &[edit], &document).expect("the model removes");
1492        assert!(!out.contains("models.\"b\""), "the model went: {out}");
1493        assert!(out.contains("# the bee model"), "its note stayed: {out}");
1494    }
1495
1496    /// **An explicit `[models]` header's comment survives its last model.**
1497    #[test]
1498    fn an_explicit_models_header_keeps_its_comment() {
1499        let raw = r#"
1500# the models
1501[models]
1502
1503[models."a"]
1504auto_compact_window = 1
1505
1506[[accounts]]
1507display_name = "One"
1508provider = "anthropic"
1509token = "t"
1510models = ["a"]
1511
1512[[orgs]]
1513name = "Acme"
1514accounts = ["One"]
1515
1516[[orgs.projects]]
1517name = "api"
1518path = "/tmp/api"
1519model = "a"
1520"#;
1521        let document = document(raw);
1522        let edit = entry_edit("models", "models.a", SettingsEditOp::Remove, None);
1523        let out = apply(raw, &[edit], &document).expect("the model removes");
1524        assert!(!out.contains("models.\"a\""), "the model went: {out}");
1525        assert!(out.contains("# the models"), "the header's note stayed: {out}");
1526    }
1527
1528    /// **An LF file stays LF when a multi-line string carries a CRLF.** The
1529    /// string's newline is content; the layout is the file's first newline.
1530    #[test]
1531    fn an_lf_file_keeps_lf_when_a_string_contains_crlf() {
1532        let raw = "[server]\nport = 8790\nnote = \"\"\"one\r\ntwo\"\"\"\n";
1533        let document = document(FIXTURE);
1534        let edit = set_edit(&document, "server.port", serde_json::json!(8791));
1535        let out = apply(raw, &[edit], &document).expect("the edit applies");
1536        assert!(out.contains("one\r\ntwo"), "the string's own CRLF stayed: {out:?}");
1537        assert!(!out.contains("\r\nport"), "the layout stayed LF: {out:?}");
1538    }
1539
1540    /// **A rename and an edit of the same entry share one batch.** The edit
1541    /// is located by the pre-edit name, so the rename must be applied after
1542    /// it whatever order the batch arrived in.
1543    #[test]
1544    fn a_rename_and_an_edit_of_the_same_entry_apply_together() {
1545        let document = document(FIXTURE);
1546        let rename =
1547            set_edit(&document, "orgs.Acme.Corp.projects.api.v2.name", serde_json::json!("api.v3"));
1548        let edit =
1549            set_edit(&document, "orgs.Acme.Corp.projects.api.v2.max_workers", serde_json::json!(4));
1550        let out = apply(FIXTURE, &[rename, edit], &document).expect("both apply");
1551        assert!(out.contains("name = \"api.v3\""), "the rename landed: {out}");
1552        assert!(out.contains("max_workers = 4"), "the edit landed: {out}");
1553    }
1554
1555    /// **An add cannot replace an existing entry.** A model table already in
1556    /// the file is refused rather than silently overwritten.
1557    #[test]
1558    fn adding_an_existing_model_is_refused() {
1559        let document = document(FIXTURE);
1560        let edit = entry_edit(
1561            "models",
1562            "models.deepseek-v4.1-flash",
1563            SettingsEditOp::Add,
1564            Some(serde_json::json!({ "auto_compact_window": 1 })),
1565        );
1566        let error = apply(FIXTURE, &[edit], &document).expect_err("the add is refused");
1567        assert!(error.message.contains("already exists"), "{}", error.message);
1568    }
1569
1570    /// **A dotted org's project is located by the row's own names.** A path
1571    /// cut would take the org's inner `.projects.` for the real one and read
1572    /// the project as the rest of the name.
1573    #[test]
1574    fn an_edit_to_a_project_under_a_dotted_org_applies() {
1575        let raw = FIXTURE.replace("name = \"Acme.Corp\"", "name = \"Acme.projects.Team\"");
1576        let document = document(&raw);
1577        let edit = set_edit(
1578            &document,
1579            "orgs.Acme.projects.Team.projects.api.v2.permission_mode",
1580            serde_json::json!("plan"),
1581        );
1582        let out = apply(&raw, &[edit], &document).expect("the dotted org's project applies");
1583        assert!(out.contains("permission_mode = \"plan\""), "{out}");
1584    }
1585
1586    /// **A Slack workspace renamed and its token edited share one batch.**
1587    /// The token edit is keyed by the pre-edit name, so the rename goes last.
1588    #[test]
1589    fn a_slack_rename_and_token_edit_apply_together() {
1590        let document = document(FIXTURE);
1591        let rename = set_edit(&document, "slack.Work.workspace", serde_json::json!("Home"));
1592        let token = set_edit(&document, "slack.Work.token", serde_json::json!("xoxp-new"));
1593        let out = apply(FIXTURE, &[token, rename], &document).expect("both apply");
1594        assert!(out.contains("workspace = \"Home\""), "{out}");
1595        assert!(out.contains("xoxp-new"), "{out}");
1596    }
1597
1598    /// **A Slack add's identity is the workspace its value names.** A value
1599    /// naming an existing workspace is refused even under a different path,
1600    /// or the file holds two entries the pump cannot tell apart.
1601    #[test]
1602    fn adding_a_slack_entry_under_an_existing_workspace_is_refused() {
1603        let document = document(FIXTURE);
1604        let edit = entry_edit(
1605            "connectors",
1606            "slack.Other",
1607            SettingsEditOp::Add,
1608            Some(serde_json::json!({ "workspace": "Work", "token": "xoxp-other" })),
1609        );
1610        let error = apply(FIXTURE, &[edit], &document).expect_err("the duplicate is refused");
1611        assert!(error.message.contains("already exists"), "{}", error.message);
1612    }
1613
1614    /// **A model's removal keeps the comment above its header.**
1615    #[test]
1616    fn a_model_removal_keeps_its_comments() {
1617        let raw = FIXTURE.replace(
1618            "[models.\"deepseek-v4.1-flash\"]",
1619            "# the flash model\n[models.\"deepseek-v4.1-flash\"]",
1620        );
1621        let document = document(&raw);
1622        let edit = entry_edit("models", "models.deepseek-v4.1-flash", SettingsEditOp::Remove, None);
1623        let out = apply(&raw, &[edit], &document).expect("the model removes");
1624        assert!(!out.contains("[models."), "the table went: {out}");
1625        assert!(out.contains("# the flash model"), "its note stayed: {out}");
1626    }
1627
1628    /// **An array's only entry takes its comment with it when removed.**
1629    #[test]
1630    fn an_only_entry_removal_keeps_its_comments() {
1631        let raw = FIXTURE.replace("[[slack]]", "# the only workspace\n[[slack]]");
1632        let document = document(&raw);
1633        let edit = entry_edit("connectors", "slack.Work", SettingsEditOp::Remove, None);
1634        let out = apply(&raw, &[edit], &document).expect("the entry removes");
1635        assert!(!out.contains("[[slack]]"), "the entry went: {out}");
1636        assert!(out.contains("# the only workspace"), "its note stayed: {out}");
1637    }
1638
1639    /// **A CRLF file keeps its endings inside a multi-line string too,**
1640    /// where the newline is content rather than layout.
1641    #[test]
1642    fn a_crlf_multi_line_string_keeps_its_line_endings() {
1643        let raw = "[server]\r\nport = 8790\r\nnote = \"\"\"one\r\ntwo\"\"\"\r\n";
1644        let document = document(FIXTURE);
1645        let edit = set_edit(&document, "server.port", serde_json::json!(8791));
1646        let out = apply(raw, &[edit], &document).expect("the edit applies");
1647        assert!(out.contains("one\r\ntwo"), "the string kept its own newline: {out:?}");
1648        assert!(!out.replace("\r\n", "").contains('\n'), "no bare LF was introduced: {out:?}");
1649    }
1650
1651    /// **A project renamed with its org in one batch applies both.** The
1652    /// project's path is keyed by the org's pre-edit name, so it goes first.
1653    #[test]
1654    fn an_org_and_its_project_rename_together() {
1655        let document = document(FIXTURE);
1656        let org = set_edit(&document, "orgs.Acme.Corp.name", serde_json::json!("Acme.Labs"));
1657        let project =
1658            set_edit(&document, "orgs.Acme.Corp.projects.api.v2.name", serde_json::json!("api.v3"));
1659        let out = apply(FIXTURE, &[org, project], &document).expect("both renames apply");
1660        assert!(out.contains("name = \"Acme.Labs\""), "{out}");
1661        assert!(out.contains("name = \"api.v3\""), "{out}");
1662    }
1663
1664    /// **A dotted org is matched from the document's own names.** An org
1665    /// whose name contains `.projects.` cannot be cut short by a split.
1666    #[test]
1667    fn a_project_under_a_dotted_org_is_removed_by_its_own_org() {
1668        let raw = FIXTURE.replace("name = \"Acme.Corp\"", "name = \"Acme.projects.Team\"");
1669        let document = document(&raw);
1670        let edit = entry_edit(
1671            "projects",
1672            "orgs.Acme.projects.Team.projects.api.v2",
1673            SettingsEditOp::Remove,
1674            None,
1675        );
1676        let out = apply(&raw, &[edit], &document).expect("the right project removes");
1677        assert!(!out.contains("name = \"api.v2\""), "the project is gone: {out}");
1678        assert!(out.contains("name = \"site\""), "its sibling stayed: {out}");
1679    }
1680}