use serde_json::{Value, json};
use speccoder_core::ConfigChange;
use super::catalogue::{CATALOGUE, Key, Kind};
use super::{json_of, lookup, validate_but_model};
pub(super) struct Place {
pub(super) tables: Vec<String>,
pub(super) leaf: String,
pub(super) key: &'static Key,
}
pub(super) fn place(dotted: &str) -> Result<Place, String> {
let parts: Vec<&str> = dotted.split('.').collect();
let Some(section) = parts
.first()
.and_then(|s| CATALOGUE.iter().find(|c| c.name == *s))
else {
if parts.first() == Some(&"ui") {
return Err("[ui] is a person's setting, not the project's: it belongs in ~/.config/speccoder/config.toml".to_owned());
}
return Err(format!(
"unknown section in `{dotted}` (known: {})",
CATALOGUE
.iter()
.map(|c| c.name)
.collect::<Vec<_>>()
.join(", ")
));
};
let (tables, rest): (Vec<String>, &[&str]) = if section.named {
let Some(name) = parts.get(1) else {
return Err(format!(
"`{dotted}`: name the table, e.g. {}.<name>.<key>",
section.name
));
};
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
return Err(format!(
"`{name}`: a table name is lowercase letters, digits, - and _"
));
}
(
vec![section.name.to_owned(), (*name).to_owned()],
&parts[2..],
)
} else {
(vec![section.name.to_owned()], &parts[1..])
};
let wanted = rest.join(".");
let key = section
.keys
.iter()
.find(|k| k.key == wanted)
.ok_or_else(|| {
format!(
"unknown key `{dotted}` (in [{}]: {})",
section.name,
section
.keys
.iter()
.map(|k| k.key)
.collect::<Vec<_>>()
.join(", ")
)
})?;
let mut tables = tables;
let mut leaf = wanted.clone();
if let Some((sub, last)) = wanted.rsplit_once('.') {
tables.extend(sub.split('.').map(str::to_owned));
leaf = last.to_owned();
}
Ok(Place { tables, leaf, key })
}
pub(super) fn check(dotted: &str, kind: Kind, value: &Value) -> Result<(), String> {
let strings = |v: &Value| v.as_array().is_some_and(|a| a.iter().all(Value::is_string));
let ok = match kind {
Kind::Bool => value.is_boolean(),
Kind::Int(a, b) => value.as_i64().is_some_and(|n| (a..=b).contains(&n)),
Kind::Num(a, b) => value.as_f64().is_some_and(|n| n >= a && n <= b),
Kind::Str => value.as_str().is_some_and(|s| !s.is_empty()),
Kind::Choice(c) => value.as_str().is_some_and(|s| c.contains(&s)),
Kind::Duration => value.as_str().is_some_and(|s| {
let (n, u) = s.split_at(s.char_indices().last().map_or(0, |(i, _)| i));
n.parse::<u64>().is_ok_and(|n| n > 0) && ["m", "h", "d"].contains(&u)
}),
Kind::Argv => strings(value) && value.as_array().is_some_and(|a| !a.is_empty()),
Kind::ArgvList => value.as_array().is_some_and(|a| {
a.iter()
.all(|x| strings(x) && x.as_array().is_some_and(|i| !i.is_empty()))
}),
Kind::StrList => strings(value),
};
if ok {
return Ok(());
}
let want = match kind {
Kind::Bool => "true or false".to_owned(),
Kind::Int(a, b) => format!("a whole number from {a} to {b}"),
Kind::Num(a, b) => format!("a number from {a} to {b}"),
Kind::Str => "text".to_owned(),
Kind::Choice(c) => format!("one of {}", c.join(", ")),
Kind::Duration => "a duration like 30m, 12h or 7d".to_owned(),
Kind::Argv => "a program and its arguments, e.g. [\"cargo\", \"fmt\"]".to_owned(),
Kind::ArgvList => {
"a list of programs with arguments, e.g. [[\"cargo\", \"check\"]]".to_owned()
}
Kind::StrList => "a list of strings".to_owned(),
};
Err(format!("`{dotted}` = {value}: expected {want}"))
}
pub(super) fn toml_value(v: &Value) -> Option<toml_edit::Value> {
Some(match v {
Value::Bool(b) => toml_edit::Value::from(*b),
Value::Number(n) => match n.as_i64() {
Some(i) => toml_edit::Value::from(i),
None => toml_edit::Value::from(n.as_f64()?),
},
Value::String(s) => toml_edit::Value::from(s.as_str()),
Value::Array(a) => {
let mut arr = toml_edit::Array::new();
for x in a {
arr.push(toml_value(x)?);
}
toml_edit::Value::Array(arr)
}
Value::Null | Value::Object(_) => return None,
})
}
pub(super) fn table_mut<'a>(
doc: &'a mut toml_edit::DocumentMut,
path: &[String],
create: bool,
) -> Result<Option<&'a mut dyn toml_edit::TableLike>, String> {
let mut t: &mut dyn toml_edit::TableLike = doc.as_table_mut();
let mut inline = false;
for (i, p) in path.iter().enumerate() {
if t.get(p).is_none() {
if !create {
return Ok(None);
}
let fresh = if inline {
toml_edit::Item::Value(toml_edit::Value::InlineTable(toml_edit::InlineTable::new()))
} else {
let mut table = toml_edit::Table::new();
let container = i == 0 && CATALOGUE.iter().any(|c| c.named && c.name == p);
table.set_implicit(container || i + 1 < path.len());
toml_edit::Item::Table(table)
};
t.insert(p, fresh);
}
let Some(item) = t.get_mut(p) else {
return Err(format!("`{}` could not be made", path[..=i].join(".")));
};
inline = inline || item.is_inline_table();
t = item
.as_table_like_mut()
.ok_or_else(|| format!("`{}` is not a table", path[..=i].join(".")))?;
}
Ok(Some(t))
}
pub(super) fn prune(doc: &mut toml_edit::DocumentMut, path: &[String]) {
for depth in (1..=path.len()).rev() {
let (parent, last) = (&path[..depth - 1], &path[depth - 1]);
let Ok(Some(t)) = table_mut(doc, parent, false) else {
return;
};
let empty = t
.get(last)
.and_then(toml_edit::Item::as_table_like)
.is_some_and(toml_edit::TableLike::is_empty);
if empty {
t.remove(last);
}
}
}
pub fn edit(text: &str, changes: &[ConfigChange]) -> Result<String, String> {
let mut doc: toml_edit::DocumentMut = text
.parse()
.map_err(|e: toml_edit::TomlError| e.to_string())?;
for c in changes {
let at = place(&c.key)?;
match (&c.value, &c.append) {
(Some(v), None) => {
check(&c.key, at.key.kind, v)?;
let value = toml_value(v)
.ok_or_else(|| format!("`{}`: a value TOML cannot hold", c.key))?;
let t = table_mut(&mut doc, &at.tables, true)?
.ok_or_else(|| format!("`{}` could not be made", c.key))?;
match t.get_mut(&at.leaf).and_then(toml_edit::Item::as_value_mut) {
Some(old) => {
let decor = old.decor().clone();
*old = value;
*old.decor_mut() = decor;
}
None => {
t.insert(&at.leaf, toml_edit::value(value));
}
}
}
(None, Some(item)) => {
if !matches!(at.key.kind, Kind::ArgvList | Kind::StrList) {
return Err(format!("`{}` is not a list", c.key));
}
let now: toml::Table = toml::from_str(&doc.to_string()).unwrap_or_default();
let mut path: Vec<&str> = at.tables.iter().map(String::as_str).collect();
path.push(&at.leaf);
let mut list: Vec<Value> = lookup(&now, &path)
.map(json_of)
.and_then(|v| v.as_array().cloned())
.unwrap_or_default();
if !list.contains(item) {
list.push(item.clone());
}
let all = Value::Array(list);
check(&c.key, at.key.kind, &all)?;
let value = toml_value(&all)
.ok_or_else(|| format!("`{}`: a value TOML cannot hold", c.key))?;
table_mut(&mut doc, &at.tables, true)?
.ok_or_else(|| format!("`{}` could not be made", c.key))?
.insert(&at.leaf, toml_edit::value(value));
}
(None, None) => {
if let Some(t) = table_mut(&mut doc, &at.tables, false)? {
t.remove(&at.leaf);
}
prune(&mut doc, &at.tables);
}
(Some(_), Some(_)) => {
return Err(format!(
"`{}`: set a value or append to it, not both",
c.key
));
}
}
}
let out = doc.to_string();
match crate::substrate::model_config::parse(&out) {
Err(e) if e.starts_with(crate::substrate::model_config::NEEDS_REGION) => {}
other => {
other?;
}
}
validate_but_model(&out)?;
Ok(out)
}
pub fn value_of(key: &str, words: &[String]) -> Result<Value, String> {
let at = place(key)?;
let joined = words.join(" ");
if let Ok(v) = serde_json::from_str::<Value>(&joined)
&& check(key, at.key.kind, &v).is_ok()
{
return Ok(v);
}
let v = match at.key.kind {
Kind::Bool => match joined.as_str() {
"true" | "on" | "yes" => json!(true),
"false" | "off" | "no" => json!(false),
_ => json!(joined),
},
Kind::Int(..) => joined
.parse::<i64>()
.map_or_else(|_| json!(joined), |n| json!(n)),
Kind::Num(..) => joined
.parse::<f64>()
.map_or_else(|_| json!(joined), |n| json!(n)),
Kind::Argv | Kind::StrList => json!(words),
Kind::ArgvList => json!([words]),
Kind::Str | Kind::Choice(_) | Kind::Duration => json!(joined),
};
check(key, at.key.kind, &v).map(|()| v)
}