use serde::Serialize;
use serde::de::DeserializeOwned;
use toml::{Table, Value};
const PROBE_KEY: &str = "v";
pub fn to_text(value: &Value) -> String {
match value {
Value::String(text) if is_bare_string(text) => text.clone(),
other => other.to_string(),
}
}
fn is_bare_string(text: &str) -> bool {
!text.is_empty() && parse_value(text).is_none()
}
pub fn parse_text(text: &str) -> Option<Value> {
let text = text.trim();
if text.is_empty() {
return None;
}
Some(parse_value(text).unwrap_or_else(|| Value::String(text.to_owned())))
}
fn parse_value(text: &str) -> Option<Value> {
let probe: Table = format!("{PROBE_KEY} = {text}").parse().ok()?;
probe.get(PROBE_KEY).cloned()
}
const KEY_SEPARATOR: char = '.';
const INDEX_OPEN: char = '[';
pub fn is_within(key: &str, outer: &str) -> bool {
let deeper = key.strip_prefix(outer);
deeper.is_some_and(|rest| rest.starts_with([KEY_SEPARATOR, INDEX_OPEN]))
}
pub fn get_path<'a>(table: &'a Table, key: &str) -> Option<&'a Value> {
match key.split_once(KEY_SEPARATOR) {
None => table.get(key),
Some((outer, inner)) => get_within(table.get(outer)?, inner),
}
}
fn get_within<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
let Value::Array(items) = value else {
return get_path(value.as_table()?, key);
};
let (at, inner) = key.split_once(KEY_SEPARATOR)?;
get_path(items.get(at.parse::<usize>().ok()?)?.as_table()?, inner)
}
pub fn set_path(table: &mut Table, key: &str, value: Option<Value>) {
let Some((outer, inner)) = key.split_once(KEY_SEPARATOR) else {
match value {
Some(value) => table.insert(key.to_owned(), value),
None => table.remove(key),
};
return;
};
let wants_list = inner
.split_once(KEY_SEPARATOR)
.is_some_and(|(at, _)| at.parse::<usize>().is_ok());
let fits = |held: &Value| held.is_array() == wants_list && (held.is_array() || held.is_table());
if value.is_none() && !table.get(outer).is_some_and(fits) {
return;
}
let held = table.entry(outer).or_insert_with(|| empty(wants_list));
if !fits(held) {
*held = empty(wants_list);
}
let is_empty = match held {
Value::Array(items) => set_in_list(items, inner, value),
Value::Table(held) => {
set_path(held, inner, value);
held.is_empty()
}
_ => false,
};
if is_empty {
table.remove(outer);
}
}
fn empty(is_list: bool) -> Value {
if is_list {
Value::Array(Vec::new())
} else {
Value::Table(Table::new())
}
}
fn set_in_list(items: &mut Vec<Value>, key: &str, value: Option<Value>) -> bool {
let Some((at, inner)) = key.split_once(KEY_SEPARATOR) else {
return items.is_empty();
};
let Ok(at) = at.parse::<usize>() else {
return items.is_empty();
};
if value.is_some() && items.len() <= at {
items.resize_with(at + 1, || Value::Table(Table::new()));
}
if let Some(Value::Table(place)) = items.get_mut(at) {
set_path(place, inner, value);
}
while items
.last()
.is_some_and(|last| last.as_table().is_some_and(Table::is_empty))
{
items.pop();
}
items.is_empty()
}
pub fn to_table<T: Serialize>(item: &T) -> Table {
toml::to_string(item)
.ok()
.and_then(|text| text.parse().ok())
.unwrap_or_default()
}
pub fn from_table<T: DeserializeOwned>(table: Table) -> Result<T, String> {
let text = toml::to_string(&table).map_err(|error| error.to_string())?;
toml::from_str(&text).map_err(|error: toml::de::Error| {
error
.message()
.lines()
.next()
.unwrap_or("invalid value")
.to_owned()
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bare_words_are_strings_and_values_are_values() {
assert_eq!(parse_text("me"), Some(Value::String("me".to_owned())));
assert_eq!(parse_text("401k"), Some(Value::String("401k".to_owned())));
assert_eq!(parse_text("2026"), Some(Value::Integer(2026)));
assert_eq!(parse_text("true"), Some(Value::Boolean(true)));
assert_eq!(parse_text(" "), None);
let trigger = parse_text("{ age = 60, owner = \"me\" }").unwrap();
assert_eq!(trigger.get("age"), Some(&Value::Integer(60)));
assert_eq!(
parse_text("\"2026\""),
Some(Value::String("2026".to_owned()))
);
}
#[test]
fn text_round_trips_through_the_value() {
for text in [
"me",
"401k",
"2026",
"true",
"{ age = 60, owner = \"me\" }",
"\"2026\"",
] {
let value = parse_text(text).unwrap();
assert_eq!(parse_text(&to_text(&value)), Some(value), "{text}");
}
assert_eq!(to_text(&Value::String("two words".to_owned())), "two words");
assert_eq!(to_text(&Value::String("2026".to_owned())), "\"2026\"");
}
#[test]
fn a_dotted_key_makes_its_table_and_the_last_value_cleared_removes_it() {
let mut item: Table = "id = \"a\"".parse().unwrap();
set_path(&mut item, "held.first", Some(Value::Integer(1)));
set_path(&mut item, "held.second", Some(Value::Integer(2)));
assert_eq!(get_path(&item, "held.second"), Some(&Value::Integer(2)));
assert_eq!(get_path(&item, "held.third"), None);
assert_eq!(get_path(&item, "absent.first"), None);
set_path(&mut item, "held.first", None);
assert!(item.contains_key("held"), "it still holds a value");
set_path(&mut item, "held.second", None);
assert!(!item.contains_key("held"), "and now holds none");
set_path(&mut item, "absent.first", None);
assert_eq!(item.len(), 1, "clearing makes no table: {item}");
set_path(&mut item, "id", None);
assert!(item.is_empty(), "a plain key is a plain key");
}
#[test]
fn a_numbered_key_reaches_into_a_list_and_keeps_its_places() {
let mut item = Table::new();
set_path(&mut item, "steps.1.share", Some(Value::Float(0.5)));
assert_eq!(get_path(&item, "steps.1.share"), Some(&Value::Float(0.5)));
assert_eq!(
item["steps"].as_array().map(Vec::len),
Some(2),
"place 0 held open"
);
set_path(&mut item, "steps.0.share", Some(Value::Float(0.2)));
set_path(&mut item, "steps.0.share", None);
assert_eq!(
get_path(&item, "steps.1.share"),
Some(&Value::Float(0.5)),
"place 1 stays"
);
set_path(&mut item, "steps.1.share", None);
assert!(
!item.contains_key("steps"),
"the last place cleared removes the list"
);
set_path(&mut item, "steps.share", Some(Value::Float(0.1)));
set_path(&mut item, "steps.0.share", Some(Value::Float(0.3)));
assert!(item["steps"].is_array(), "writing a place replaces a table");
set_path(&mut item, "steps.share", None);
assert!(
item["steps"].is_array(),
"clearing a key the list lacks leaves it"
);
}
#[test]
fn dates_stay_native_toml_dates() {
let person: retiretui_engine::plan::Person =
toml::from_str("id = \"a\"\nbirth = 1980-01-02").unwrap();
let table = to_table(&person);
assert_eq!(to_text(&table["birth"]), "1980-01-02");
assert_eq!(
from_table::<retiretui_engine::plan::Person>(table),
Ok(person)
);
}
#[test]
fn tables_surface_the_schemas_error() {
let mut table = Table::new();
table.insert("id".to_owned(), Value::String("me".to_owned()));
let error = from_table::<retiretui_engine::plan::Person>(table).unwrap_err();
assert!(error.contains("birth"), "{error}");
}
}