1use forge_primitives::settings::{
10 EditKind, SettingsDocument, SettingsEdit, SettingsEditOp, SettingsEditRequest, SettingsKey,
11};
12use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Table, Value};
13
14#[derive(Debug, Clone, PartialEq, Eq)]
16pub(crate) struct WriteError {
17 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
27pub(crate) fn apply(
29 current: &str,
30 edits: &[SettingsEditRequest],
31 document: &SettingsDocument,
32) -> Result<String, WriteError> {
33 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 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 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
86fn 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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
214enum Step {
215 Key(String),
216 Account(String),
217 Org(String),
218 Project(String),
219 Slack(String),
220}
221
222fn 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
239fn locate_from_group(key: &SettingsKey, parts: &[Part]) -> Option<(Vec<Step>, String)> {
242 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
256fn 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
268fn 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 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
328pub(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
337fn 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 Part::Wildcard => rest.is_empty() || split_then(path, names, rest),
365 }
366}
367
368fn 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 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
409enum 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 fn remove(self, key: &str) -> Option<String> {
444 match self {
445 Self::Table(table) => {
446 let next = next_key_after(table, key);
451 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
500fn 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
514fn 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
519fn 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
532fn 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
571fn keep_prefix(table: &mut Table, prefix: Option<String>) {
574 if let Some(prefix) = prefix {
575 keep_suffix(table, &prefix);
576 }
577}
578
579fn 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
586fn 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
682pub(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
692fn 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
740fn 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
800fn 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 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
945fn 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 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 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 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
1054fn 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}