use toml_edit::{Array, DocumentMut, Item, Value};
#[derive(Debug, Clone)]
pub struct ConfigDocument {
doc: DocumentMut,
}
#[derive(Debug, thiserror::Error)]
pub enum EditError {
#[error("brink.toml is not valid TOML: {0}")]
Parse(#[from] toml_edit::TomlError),
#[error("brink.toml has `{path}` as {found}, expected {expected}")]
Shape {
path: String,
found: &'static str,
expected: &'static str,
},
}
impl ConfigDocument {
pub fn parse(text: &str) -> Result<Self, EditError> {
Ok(Self {
doc: text.parse::<DocumentMut>()?,
})
}
#[must_use]
pub fn empty() -> Self {
Self {
doc: DocumentMut::new(),
}
}
#[must_use]
pub fn to_toml_string(&self) -> String {
self.doc.to_string()
}
pub fn set_integer(&mut self, table: &str, key: &str, value: i64) -> Result<(), EditError> {
let entry = self.table_mut(table)?;
Self::assign_keeping_decor(entry, key, toml_edit::value(value));
Ok(())
}
pub fn set_string(&mut self, table: &str, key: &str, value: &str) -> Result<(), EditError> {
let entry = self.table_mut(table)?;
Self::assign_keeping_decor(entry, key, toml_edit::value(value));
Ok(())
}
pub fn string_array(&self, table: &str, key: &str) -> Result<Vec<String>, EditError> {
let Some(item) = self.doc.get(table).and_then(|t| t.get(key)) else {
return Ok(Vec::new());
};
if item.is_none() {
return Ok(Vec::new());
}
let Some(array) = item.as_array() else {
return Err(EditError::Shape {
path: format!("{table}.{key}"),
found: "a non-array",
expected: "an array of strings",
});
};
array
.iter()
.map(|v| {
v.as_str()
.map(str::to_owned)
.ok_or_else(|| EditError::Shape {
path: format!("{table}.{key}"),
found: "an array with a non-string element",
expected: "an array of strings",
})
})
.collect()
}
pub fn add_to_string_array(
&mut self,
table: &str,
key: &str,
value: &str,
) -> Result<bool, EditError> {
if self.string_array(table, key)?.iter().any(|v| v == value) {
return Ok(false);
}
let entry = self.table_mut(table)?;
if entry.get(key).is_none_or(Item::is_none) {
entry[key] = Item::Value(Value::Array(Array::new()));
}
let item = &mut entry[key];
let Some(array) = item.as_array_mut() else {
return Err(EditError::Shape {
path: format!("{table}.{key}"),
found: "a non-array",
expected: "an array of strings",
});
};
array.push(value);
Ok(true)
}
pub fn remove_from_string_array(
&mut self,
table: &str,
key: &str,
value: &str,
) -> Result<bool, EditError> {
let Some(item) = self.doc.get_mut(table).and_then(|t| t.get_mut(key)) else {
return Ok(false);
};
if item.is_none() {
return Ok(false);
}
let Some(array) = item.as_array_mut() else {
return Err(EditError::Shape {
path: format!("{table}.{key}"),
found: "a non-array",
expected: "an array of strings",
});
};
let before = array.len();
array.retain(|v| v.as_str() != Some(value));
Ok(array.len() != before)
}
pub fn remove_key(&mut self, table: &str, key: &str) -> Result<bool, EditError> {
let Some(existing) = self.doc.get_mut(table) else {
return Ok(false);
};
let Some(table_like) = existing.as_table_like_mut() else {
return Err(EditError::Shape {
path: table.to_owned(),
found: "a non-table",
expected: "a table",
});
};
Ok(table_like.remove(key).is_some())
}
pub fn set_bool(&mut self, table: &str, key: &str, value: bool) -> Result<(), EditError> {
let entry = self.table_mut(table)?;
Self::assign_keeping_decor(entry, key, toml_edit::value(value));
Ok(())
}
#[must_use]
pub fn bool(&self, table: &str, key: &str) -> Option<bool> {
self.doc
.get(table)
.and_then(|t| t.get(key))
.and_then(Item::as_bool)
}
#[must_use]
pub fn integer(&self, table: &str, key: &str) -> Option<i64> {
self.doc
.get(table)
.and_then(|t| t.get(key))
.and_then(Item::as_integer)
}
#[must_use]
pub fn keys(&self, table: &str) -> Vec<String> {
self.doc
.get(table)
.and_then(Item::as_table_like)
.map(|t| t.iter().map(|(k, _)| k.to_owned()).collect())
.unwrap_or_default()
}
#[must_use]
pub fn string(&self, table: &str, key: &str) -> Option<String> {
self.doc
.get(table)
.and_then(|t| t.get(key))
.and_then(Item::as_str)
.map(str::to_owned)
}
fn assign_keeping_decor(entry: &mut Item, key: &str, item: Item) {
let decor = entry
.get(key)
.and_then(Item::as_value)
.map(|v| v.decor().clone());
entry[key] = item;
if let (Some(decor), Some(value)) = (decor, entry[key].as_value_mut()) {
*value.decor_mut() = decor;
}
}
fn table_mut(&mut self, table: &str) -> Result<&mut Item, EditError> {
if let Some(existing) = self.doc.get(table) {
if !existing.is_table_like() {
return Err(EditError::Shape {
path: table.to_owned(),
found: "a non-table",
expected: "a table",
});
}
} else {
self.doc[table] = Item::Table(toml_edit::Table::new());
}
Ok(&mut self.doc[table])
}
}
#[cfg(test)]
mod tests {
use super::*;
const HAND_WRITTEN: &str = "\
# The story's entry point.
[project]
entry = 'main.ink' # kept deliberately in single quotes
dialect = \"screenplay\"
# Loud about unreachable content.
[lints]
E063 = \"warn\"
";
#[test]
fn an_edit_leaves_every_untouched_byte_alone() {
let mut doc = ConfigDocument::parse(HAND_WRITTEN).expect("valid toml");
doc.add_to_string_array("project", "drafts", "scratch/*.ink")
.expect("array edit");
let out = doc.to_toml_string();
assert!(
out.contains("# The story's entry point."),
"leading comment survives"
);
assert!(
out.contains("# kept deliberately in single quotes"),
"trailing comment survives"
);
assert!(out.contains("entry = 'main.ink'"), "quote style survives");
assert!(
out.contains("# Loud about unreachable content."),
"section comment survives"
);
assert!(out.contains("E063 = \"warn\""), "unrelated table survives");
assert!(out.contains("scratch/*.ink"));
}
#[test]
fn adding_a_value_already_present_changes_nothing() {
let mut doc = ConfigDocument::parse("[project]\ndrafts = [\"a.ink\"]\n").expect("valid");
assert!(
!doc.add_to_string_array("project", "drafts", "a.ink")
.expect("edit")
);
assert_eq!(doc.to_toml_string(), "[project]\ndrafts = [\"a.ink\"]\n");
}
#[test]
fn arrays_and_tables_are_created_when_absent() {
let mut doc = ConfigDocument::empty();
assert!(
doc.add_to_string_array("project", "drafts", "scratch/*.ink")
.expect("edit")
);
assert_eq!(
doc.string_array("project", "drafts").expect("read"),
vec!["scratch/*.ink"]
);
}
#[test]
fn removing_reports_whether_anything_changed() {
let mut doc =
ConfigDocument::parse("[project]\ndrafts = [\"a.ink\", \"b.ink\"]\n").expect("valid");
assert!(
doc.remove_from_string_array("project", "drafts", "a.ink")
.expect("edit")
);
assert!(
!doc.remove_from_string_array("project", "drafts", "a.ink")
.expect("edit")
);
assert_eq!(
doc.string_array("project", "drafts").expect("read"),
vec!["b.ink"]
);
}
#[test]
fn removing_from_an_absent_key_is_a_no_op_not_an_error() {
let mut doc = ConfigDocument::parse("[project]\n").expect("valid");
assert!(
!doc.remove_from_string_array("project", "drafts", "a.ink")
.expect("edit")
);
}
#[test]
fn set_integer_and_string_round_trip() {
let mut doc = ConfigDocument::parse(HAND_WRITTEN).expect("valid");
doc.set_integer("format", "indent", 2).expect("edit");
doc.set_string("project", "entry", "other.ink")
.expect("edit");
let out = doc.to_toml_string();
assert!(out.contains("indent = 2"));
assert!(out.contains("other.ink"));
assert!(out.contains("# kept deliberately in single quotes"));
}
#[test]
fn a_wrong_shaped_key_is_reported_not_clobbered() {
let mut doc = ConfigDocument::parse("[project]\ndrafts = \"oops\"\n").expect("valid");
let err = doc
.add_to_string_array("project", "drafts", "a.ink")
.unwrap_err();
assert!(matches!(err, EditError::Shape { .. }), "got {err:?}");
assert_eq!(
doc.to_toml_string(),
"[project]\ndrafts = \"oops\"\n",
"document untouched"
);
}
#[test]
fn a_non_table_where_a_table_belongs_is_reported() {
let mut doc = ConfigDocument::parse("project = 3\n").expect("valid");
let err = doc.set_integer("project", "x", 1).unwrap_err();
assert!(matches!(err, EditError::Shape { .. }), "got {err:?}");
}
#[test]
fn invalid_toml_fails_to_parse_rather_than_being_repaired() {
assert!(matches!(
ConfigDocument::parse("[project"),
Err(EditError::Parse(_))
));
}
#[test]
fn remove_key_takes_one_key_and_nothing_else() {
let mut doc = ConfigDocument::parse(HAND_WRITTEN).expect("valid toml");
assert_eq!(
doc.string("project", "dialect").as_deref(),
Some("screenplay")
);
assert!(doc.remove_key("project", "dialect").expect("a table"));
let out = doc.to_toml_string();
assert!(!out.contains("dialect"), "the key is gone: {out}");
assert!(
out.contains("entry = 'main.ink' # kept deliberately in single quotes"),
"its neighbour is untouched, comment and quotes included: {out}"
);
assert!(out.contains("# Loud about unreachable content."));
assert_eq!(doc.string("project", "dialect"), None);
assert!(
!doc.remove_key("project", "dialect").expect("a table"),
"removing an absent key is a no-op"
);
assert!(
!doc.remove_key("nowhere", "entry")
.expect("an absent table is fine"),
"an absent table is a no-op too"
);
let mut scalar = ConfigDocument::parse("project = 1\n").expect("valid toml");
assert!(
matches!(
scalar.remove_key("project", "entry"),
Err(EditError::Shape { .. })
),
"a table that is not a table is reported, never clobbered"
);
}
#[test]
fn scalar_readers_and_the_key_list_see_what_is_written() {
let mut doc = ConfigDocument::parse(HAND_WRITTEN).expect("valid toml");
assert_eq!(
doc.keys("project"),
["entry", "dialect"],
"in written order"
);
assert_eq!(doc.keys("lints"), ["E063"]);
assert!(doc.keys("nowhere").is_empty());
assert_eq!(doc.bool("lints", "deny-warnings"), None);
assert_eq!(doc.integer("project", "indent"), None);
assert_eq!(doc.bool("project", "entry"), None, "a string is not a bool");
doc.set_bool("lints", "deny-warnings", true)
.expect("a table");
doc.set_integer("project", "indent", 2).expect("a table");
assert_eq!(doc.bool("lints", "deny-warnings"), Some(true));
assert_eq!(doc.integer("project", "indent"), Some(2));
assert_eq!(doc.keys("lints"), ["E063", "deny-warnings"]);
let out = doc.to_toml_string();
assert!(out.contains("deny-warnings = true"), "{out}");
assert!(out.contains("# Loud about unreachable content."));
}
}