use serde_json::Value;
use super::super::edits::{EditKey, Edits};
#[cfg(doc)]
use super::super::field::flattened;
use super::super::field::{Entries, FieldKind};
use super::entries_write::set_entries;
use super::{ConfigPane, PaneEdit, PathStep, SectionWrite};
impl ConfigPane {
#[must_use]
pub fn edited_section_with(&self, edits: &Edits) -> SectionWrite {
let Some(section) = self.section_text() else {
return SectionWrite::Nothing;
};
let Ok(mut doc) = section.parse::<toml_edit::DocumentMut>() else {
return SectionWrite::Refused;
};
let mut changed = false;
for (key, edit) in edits.iter() {
let EditKey::Field(name) = key else {
continue;
};
let PaneEdit::Set { value, .. } = edit.edit() else {
continue;
};
let value = value.as_value();
if let Some((kind, path)) = self.entries_field(name) {
match set_entries(&mut doc, kind, path, value) {
Landed::Changed => changed = true,
Landed::Already => {}
Landed::Refused => return SectionWrite::Refused,
}
continue;
}
let Some((path, existing)) = self.write_path(name) else {
return SectionWrite::Refused;
};
let landed = match toml_cell(value) {
Some(cell) => set_at(doc.as_table_mut(), &path, existing, cell, false),
None if value.is_null() => remove_at(doc.as_table_mut(), &path, existing),
None => Landed::Refused,
};
match landed {
Landed::Changed => changed = true,
Landed::Already => {}
Landed::Refused => return SectionWrite::Refused,
}
}
if changed {
SectionWrite::Written(doc.to_string().into())
} else {
SectionWrite::Nothing
}
}
}
impl ConfigPane {
pub(super) fn entries_field(&self, key: &str) -> Option<(Entries, &[String])> {
let FieldKind::Entries(kind) = self.fields.by_key(key)?.kind else {
return None;
};
Some((kind, self.section_paths.get(key)?))
}
fn write_path(&self, key: &str) -> Option<(Vec<PathStep>, usize)> {
if let Some(leaf) = self.entries.leaf(key) {
return Some((leaf.at.to_vec(), leaf.entry_len));
}
let path = self.section_paths.get(key)?;
Some((path.iter().cloned().map(PathStep::Key).collect(), 0))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Landed {
Changed,
Already,
Refused,
}
pub(super) fn toml_cell(value: &Value) -> Option<toml_edit::Item> {
match value {
Value::Bool(flag) => Some(toml_edit::value(*flag)),
Value::Number(number) => {
if let Some(int) = number.as_i64() {
Some(toml_edit::value(int))
} else if number.is_f64() {
number.as_f64().map(toml_edit::value)
} else {
None
}
}
Value::String(text) => Some(toml_edit::value(text.as_str())),
Value::Array(elements) => {
toml_array(elements).map(|array| toml_edit::Item::Value(toml_edit::Value::Array(array)))
}
Value::Null | Value::Object(_) => None,
}
}
fn toml_array(elements: &[Value]) -> Option<toml_edit::Array> {
if elements.iter().all(Value::is_string) {
elements
.iter()
.map(|element| element.as_str().map(toml_edit::Value::from))
.collect()
} else {
elements
.iter()
.map(|element| element.as_i64().map(toml_edit::Value::from))
.collect()
}
}
pub(super) fn set_at(
table: &mut dyn toml_edit::TableLike,
path: &[PathStep],
existing: usize,
cell: toml_edit::Item,
inline: bool,
) -> Landed {
let Some((PathStep::Key(key), rest)) = path.split_first() else {
return Landed::Refused;
};
if let Some((PathStep::Index(at), after)) = rest.split_first() {
let Some((entry, entry_inline)) =
table.get_mut(key).and_then(|array| element_at(array, *at))
else {
return Landed::Refused;
};
return set_at(entry, after, existing.saturating_sub(2), cell, entry_inline);
}
if existing > 0 && table.get(key).is_none_or(toml_edit::Item::is_none) {
return Landed::Refused;
}
let entry = table.entry(key).or_insert(toml_edit::Item::None);
if rest.is_empty() {
if entry.is_table_like() {
return Landed::Refused;
}
*entry = cell;
return Landed::Changed;
}
let nested_inline = if entry.is_none() {
*entry = if inline {
inline_child()
} else {
implicit_table()
};
inline
} else if entry.is_inline_table() {
true
} else if !entry.is_table_like() {
return Landed::Refused;
} else {
false
};
let Some(nested) = entry.as_table_like_mut() else {
return Landed::Refused;
};
set_at(
nested,
rest,
existing.saturating_sub(1),
cell,
nested_inline,
)
}
pub(super) fn remove_at(
table: &mut dyn toml_edit::TableLike,
path: &[PathStep],
existing: usize,
) -> Landed {
let Some((PathStep::Key(key), rest)) = path.split_first() else {
return Landed::Refused;
};
if let Some((PathStep::Index(at), after)) = rest.split_first() {
let Some((entry, _)) = table.get_mut(key).and_then(|array| element_at(array, *at)) else {
return Landed::Refused;
};
return remove_at(entry, after, existing.saturating_sub(2));
}
if rest.is_empty() {
return if table.remove(key).is_some() {
Landed::Changed
} else {
Landed::Already
};
}
let Some(item) = table.get_mut(key) else {
return if existing > 0 {
Landed::Refused
} else {
Landed::Already
};
};
if matches!(item, toml_edit::Item::None) {
return Landed::Already;
}
let Some(nested) = item.as_table_like_mut() else {
return Landed::Refused;
};
remove_at(nested, rest, existing.saturating_sub(1))
}
fn element_at(
item: &mut toml_edit::Item,
at: usize,
) -> Option<(&mut dyn toml_edit::TableLike, bool)> {
match item {
toml_edit::Item::ArrayOfTables(tables) => tables
.get_mut(at)
.map(|table| (table as &mut dyn toml_edit::TableLike, false)),
toml_edit::Item::Value(toml_edit::Value::Array(array)) => array
.get_mut(at)?
.as_inline_table_mut()
.map(|table| (table as &mut dyn toml_edit::TableLike, true)),
_ => None,
}
}
pub(super) fn implicit_table() -> toml_edit::Item {
let mut table = toml_edit::Table::new();
table.set_implicit(true);
toml_edit::Item::Table(table)
}
pub(super) fn inline_child() -> toml_edit::Item {
toml_edit::Item::Value(toml_edit::Value::InlineTable(toml_edit::InlineTable::new()))
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::super::fixtures::{bark_pane, field, money_pane, web};
use super::*;
#[test]
fn a_float_replaced_in_a_dogs_section_keeps_the_comment_above_it() {
let mut pane = money_pane("# money cap\nper_item_usd = 10\nname = \"kelpie\"\n");
pane.move_to_key("per_item_usd");
pane.begin_typing();
for _ in 0..10 {
pane.type_backspace();
}
for c in "12.5".chars() {
pane.type_char(c);
}
assert_eq!(pane.apply_typing(), None, "12.5 is above the floor");
let SectionWrite::Written(section) = pane.edited_section_with(pane.edits()) else {
panic!("the fixture section parses, and the replacement lands in it");
};
assert_eq!(
section.as_str(),
"# money cap\nper_item_usd = 12.5\nname = \"kelpie\"\n"
);
}
#[test]
fn a_whole_number_filed_for_a_number_field_writes_a_float() {
let mut pane = money_pane("per_item_usd = 12.5\n");
pane.move_to_key("per_item_usd");
pane.begin_typing();
for _ in 0..4 {
pane.type_backspace();
}
for c in "10".chars() {
pane.type_char(c);
}
assert_eq!(pane.apply_typing(), None, "10 is a number");
let SectionWrite::Written(section) = pane.edited_section_with(pane.edits()) else {
panic!("the fixture section parses, and the replacement lands in it");
};
assert_eq!(section.as_str(), "per_item_usd = 10.0\n");
}
fn nested_schema() -> Value {
json!({
"properties": {
"name": { "type": "string" },
"models": { "$ref": "#/$defs/Models" },
"a.b": { "type": "string" },
},
"$defs": {
"Models": {
"type": "object",
"properties": { "worker": { "$ref": "#/$defs/Worker" } },
},
"Worker": {
"type": "object",
"properties": {
"model": { "type": "string" },
"token": { "type": "string", "x-shep-secret": true },
},
},
},
})
}
fn nested_pane(section: &str) -> ConfigPane {
ConfigPane::dog("jobs".into(), None, nested_schema(), section.to_owned())
}
fn list_pane(section: &str) -> ConfigPane {
let mut schema = nested_schema();
schema["properties"]["hosts"] = json!({ "type": "array", "items": { "type": "string" } });
schema["properties"]["codes"] = json!({ "type": "array", "items": { "type": "integer" } });
schema["$defs"]["Worker"]["properties"]["models"] =
json!({ "type": "array", "items": { "type": "string" } });
ConfigPane::dog("jobs".into(), None, schema, section.to_owned())
}
const HEADER: &str = concat!(
"# which model to run\n",
"name = \"small\"\n",
"\n",
"[models.worker]\n",
"# the credential\n",
"token = \"sk-old\"\n",
"model = \"gpt\"\n",
);
const DOTTED: &str = concat!(
"# which model to run\n",
"name = \"small\"\n",
"models.worker.token = \"sk-old\"\n",
"models.worker.model = \"gpt\"\n",
);
const INLINE: &str = concat!(
"# which model to run\n",
"name = \"small\"\n",
"models = { worker = { token = \"sk-old\", model = \"gpt\" } }\n",
);
const NO_TOKEN: &str = concat!(
"# which model to run\n",
"name = \"small\"\n",
"\n",
"[models.worker]\n",
"model = \"gpt\"\n",
);
fn edits_of(pairs: &[(&str, Value)]) -> Edits {
let mut edits = Edits::default();
for (key, value) in pairs {
edits.set(field(key, value.clone()), None);
}
edits
}
fn want_written(write: SectionWrite) -> String {
match write {
SectionWrite::Written(toml) => toml.as_str().to_owned(),
other => panic!("wanted a section to send, got {other:?}"),
}
}
#[test]
fn a_nested_set_lands_in_the_form_the_section_already_uses() {
for (section, want) in [
(
HEADER,
concat!(
"# which model to run\n",
"name = \"small\"\n",
"\n",
"[models.worker]\n",
"# the credential\n",
"token = \"sk-old\"\n",
"model = \"large\"\n",
),
),
(
DOTTED,
concat!(
"# which model to run\n",
"name = \"small\"\n",
"models.worker.token = \"sk-old\"\n",
"models.worker.model = \"large\"\n",
),
),
(
INLINE,
concat!(
"# which model to run\n",
"name = \"small\"\n",
"models = { worker = { token = \"sk-old\", model = \"large\" } }\n",
),
),
] {
let out = want_written(
nested_pane(section)
.edited_section_with(&edits_of(&[("models.worker.model", json!("large"))])),
);
assert_eq!(out, want, "{section}");
}
}
#[test]
fn a_set_that_creates_a_table_writes_no_empty_parent_header() {
let out = want_written(
nested_pane("name = \"small\"\n")
.edited_section_with(&edits_of(&[("models.worker.model", json!("large"))])),
);
assert_eq!(
out,
concat!(
"name = \"small\"\n",
"\n",
"[models.worker]\n",
"model = \"large\"\n",
)
);
}
#[test]
fn key_order_survives_a_set() {
let out = want_written(nested_pane(HEADER).edited_section_with(&edits_of(&[
("models.worker.token", json!("sk-new")),
("name", json!("large")),
])));
assert_eq!(
out,
concat!(
"# which model to run\n",
"name = \"large\"\n",
"\n",
"[models.worker]\n",
"# the credential\n",
"token = \"sk-new\"\n",
"model = \"gpt\"\n",
)
);
}
#[test]
fn two_edits_in_one_table_both_land() {
let out = want_written(nested_pane(HEADER).edited_section_with(&edits_of(&[
("models.worker.model", json!("large")),
("models.worker.token", json!("sk-new")),
])));
assert_eq!(
out,
concat!(
"# which model to run\n",
"name = \"small\"\n",
"\n",
"[models.worker]\n",
"# the credential\n",
"token = \"sk-new\"\n",
"model = \"large\"\n",
)
);
}
#[test]
fn a_null_removes_only_its_leaf() {
for (section, want) in [
(
HEADER,
concat!(
"# which model to run\n",
"name = \"small\"\n",
"\n",
"[models.worker]\n",
"# the credential\n",
"token = \"sk-old\"\n",
),
),
(
DOTTED,
concat!(
"# which model to run\n",
"name = \"small\"\n",
"models.worker.token = \"sk-old\"\n",
),
),
(
INLINE,
concat!(
"# which model to run\n",
"name = \"small\"\n",
"models = { worker = { token = \"sk-old\"} }\n",
),
),
] {
let out = want_written(
nested_pane(section)
.edited_section_with(&edits_of(&[("models.worker.model", Value::Null)])),
);
assert_eq!(out, want, "{section}");
}
}
#[test]
fn a_null_under_an_absent_parent_writes_nothing() {
assert_eq!(
nested_pane("name = \"small\"\n")
.edited_section_with(&edits_of(&[("models.worker.model", Value::Null)])),
SectionWrite::Nothing
);
}
#[test]
fn a_null_on_a_key_the_section_does_not_carry_keeps_the_rest_of_the_batch() {
let out = want_written(nested_pane(NO_TOKEN).edited_section_with(&edits_of(&[
("models.worker.token", Value::Null),
("name", json!("large")),
])));
assert_eq!(
out,
concat!(
"# which model to run\n",
"name = \"large\"\n",
"\n",
"[models.worker]\n",
"model = \"gpt\"\n",
)
);
}
#[test]
fn a_null_under_an_absent_parent_keeps_the_rest_of_the_batch() {
let out = want_written(
nested_pane("name = \"small\"\n").edited_section_with(&edits_of(&[
("models.worker.model", Value::Null),
("name", json!("large")),
])),
);
assert_eq!(out, "name = \"large\"\n");
}
#[test]
fn a_set_onto_a_scalar_where_the_schema_has_a_table_writes_nothing() {
for section in [
"models = \"old\"\nname = \"small\"\n",
"models.worker = \"old\"\nname = \"small\"\n",
"models = { worker = \"old\" }\nname = \"small\"\n",
] {
let pane = nested_pane(section);
assert_eq!(
pane.edited_section_with(&edits_of(&[("models.worker.model", json!("large"))])),
SectionWrite::Refused,
"{section}"
);
assert_eq!(
pane.edited_section_with(&edits_of(&[
("models.worker.model", json!("large")),
("name", json!("large")),
])),
SectionWrite::Refused,
"{section}"
);
}
}
#[test]
fn a_set_never_replaces_a_table_the_operator_wrote() {
let mut doc: toml_edit::DocumentMut = "[models.worker]\nmodel = \"gpt\"\n"
.parse()
.expect("the section parses");
let path = [PathStep::Key("models".to_owned())];
assert_eq!(
set_at(doc.as_table_mut(), &path, 0, toml_edit::value("old"), false),
Landed::Refused
);
assert!(doc.to_string().contains("[models.worker]"), "{doc}");
}
#[test]
fn a_set_inside_an_inline_table_creates_an_inline_table() {
for (section, want) in [
(
"models = {}\nname = \"small\"\n",
"models = { worker = { model = \"large\" } }\nname = \"small\"\n",
),
(
"models = { token = \"sk-old\" }\nname = \"small\"\n",
concat!(
"models = { token = \"sk-old\" , worker = { model = \"large\" } }\n",
"name = \"small\"\n",
),
),
(
"models = { worker = {} }\nname = \"small\"\n",
"models = { worker = { model = \"large\" } }\nname = \"small\"\n",
),
(
"models = { worker = { token = \"sk-old\" } }\nname = \"small\"\n",
concat!(
"models = { worker = { token = \"sk-old\" , model = \"large\" } }\n",
"name = \"small\"\n",
),
),
] {
let out = want_written(
nested_pane(section)
.edited_section_with(&edits_of(&[("models.worker.model", json!("large"))])),
);
assert_eq!(out, want, "{section}");
let doc: toml_edit::DocumentMut = out.parse().expect("the output parses");
assert_eq!(
doc["models"]["worker"]["model"].as_str(),
Some("large"),
"{out}"
);
}
}
#[test]
fn a_property_named_with_a_dot_stays_one_key() {
let pane = nested_pane("\"a.b\" = \"literal\"\nname = \"small\"\n");
assert_eq!(pane.value("\"a.b\""), "literal");
let out = want_written(pane.edited_section_with(&edits_of(&[("\"a.b\"", json!("other"))])));
assert_eq!(out, "\"a.b\" = \"other\"\nname = \"small\"\n");
}
#[test]
fn a_number_that_is_neither_an_integer_nor_a_float_writes_nothing() {
let pane = nested_pane("name = \"small\"\n");
assert_eq!(
pane.edited_section_with(&edits_of(&[("name", json!(u64::MAX))])),
SectionWrite::Refused
);
}
#[test]
fn a_mixed_array_an_array_of_objects_or_a_map_writes_nothing() {
let pane = nested_pane("name = \"small\"\n");
for value in [
json!(["a", 1]),
json!([1.5, 2.5]),
json!([true]),
json!([null]),
json!([["a"]]),
json!([{ "model": "gpt" }]),
json!({ "model": "gpt" }),
] {
assert_eq!(
pane.edited_section_with(&edits_of(&[("name", value.clone())])),
SectionWrite::Refused,
"{value}"
);
}
}
#[test]
fn a_string_list_replaces_its_line_and_keeps_the_comment_above_it() {
let out = want_written(
list_pane("# who to ask\nhosts = [\"a\"]\nname = \"small\"\n")
.edited_section_with(&edits_of(&[("hosts", json!(["a", "b"]))])),
);
assert_eq!(
out,
"# who to ask\nhosts = [\"a\", \"b\"]\nname = \"small\"\n"
);
}
#[test]
fn an_integer_list_writes_integers_not_text() {
let out = want_written(
list_pane("codes = [0]\nname = \"small\"\n")
.edited_section_with(&edits_of(&[("codes", json!([0, 143]))])),
);
assert_eq!(out, "codes = [0, 143]\nname = \"small\"\n");
}
#[test]
fn an_empty_list_writes_an_empty_array() {
let out = want_written(
list_pane("hosts = [\"a\"]\n").edited_section_with(&edits_of(&[("hosts", json!([]))])),
);
assert_eq!(out, "hosts = []\n");
}
#[test]
fn a_list_the_section_lacks_is_added_after_the_keys_it_has() {
let out = want_written(
list_pane("name = \"small\"\n")
.edited_section_with(&edits_of(&[("hosts", json!(["a"]))])),
);
assert_eq!(out, "name = \"small\"\nhosts = [\"a\"]\n");
}
#[test]
fn a_list_under_a_nested_table_lands_at_its_dotted_path() {
for (section, want) in [
(
"[models.worker]\n# the pool\nmodels = [\"gpt\"]\n",
"[models.worker]\n# the pool\nmodels = [\"gpt\", \"large\"]\n",
),
(
"models.worker.models = [\"gpt\"]\n",
"models.worker.models = [\"gpt\", \"large\"]\n",
),
(
"[models.worker]\nmodel = \"gpt\"\n",
"[models.worker]\nmodel = \"gpt\"\nmodels = [\"gpt\", \"large\"]\n",
),
] {
let out = want_written(list_pane(section).edited_section_with(&edits_of(&[(
"models.worker.models",
json!(["gpt", "large"]),
)])));
assert_eq!(out, want, "{section}");
}
}
#[test]
fn a_scalar_writes_its_own_toml_type() {
for (value, want) in [
(json!(-5), "name = -5\n"),
(json!(1.5), "name = 1.5\n"),
(json!(true), "name = true\n"),
(json!("gpt"), "name = \"gpt\"\n"),
] {
let pane = nested_pane("name = 0\n");
let out = want_written(pane.edited_section_with(&edits_of(&[("name", value.clone())])));
assert_eq!(out, want, "{value}");
}
}
#[test]
fn an_edited_section_keeps_its_comments_and_changes_one_key() {
let pane = bark_pane();
let mut edits = Edits::default();
edits.set(field("poll", json!("30s")), None);
let out = want_written(pane.edited_section_with(&edits));
assert!(out.contains("# how often"), "{out}");
assert!(out.contains("poll = \"30s\""), "{out}");
assert!(out.contains("history_bytes = 4096"), "{out}");
assert!(out.contains("url = \"https://hooks.example/x\""), "{out}");
}
#[test]
fn a_null_edit_removes_the_key_from_the_section() {
let pane = bark_pane();
let mut edits = Edits::default();
edits.set(field("history_bytes", Value::Null), None);
let out = want_written(pane.edited_section_with(&edits));
assert!(!out.contains("history_bytes"), "{out}");
assert!(out.contains("# how often"), "{out}");
}
#[test]
fn a_dog_section_takes_every_filed_edit_in_one_write() {
let pane = bark_pane();
let mut edits = Edits::default();
edits.set(field("sink_timeout", json!("5s")), None);
edits.set(field("poll", json!("30s")), None);
let section = want_written(pane.edited_section_with(&edits));
assert!(section.contains("poll = \"30s\""), "{section}");
assert!(section.contains("sink_timeout = \"5s\""), "{section}");
}
#[test]
fn an_env_edit_is_ignored_by_a_dog_section() {
let pane = bark_pane();
let mut edits = Edits::default();
edits.set(
PaneEdit::SetEnv {
key: "SECRET".to_owned(),
value: None,
},
None,
);
assert_eq!(pane.edited_section_with(&edits), SectionWrite::Nothing);
}
#[test]
fn a_field_edit_lands_even_when_the_same_batch_carries_an_env_edit() {
let pane = bark_pane();
let mut edits = Edits::default();
edits.set(field("poll", json!("30s")), None);
edits.set(
PaneEdit::SetEnv {
key: "SECRET".to_owned(),
value: None,
},
None,
);
let out = want_written(pane.edited_section_with(&edits));
assert!(out.contains("poll = \"30s\""), "{out}");
assert!(!out.contains("SECRET"), "{out}");
}
#[test]
fn a_sheep_pane_has_no_section_to_edit() {
let pane = ConfigPane::sheep(web());
let mut edits = Edits::default();
edits.set(field("cwd", json!("/srv")), None);
assert_eq!(pane.edited_section_with(&edits), SectionWrite::Nothing);
}
}