speccoder_runtime/settings/
edit.rs1use serde_json::{Value, json};
5
6use speccoder_core::ConfigChange;
7
8use super::catalogue::{CATALOGUE, Key, Kind};
9use super::{json_of, lookup, validate_but_model};
10
11pub(super) struct Place {
14 pub(super) tables: Vec<String>,
15 pub(super) leaf: String,
16 pub(super) key: &'static Key,
17}
18
19pub(super) fn place(dotted: &str) -> Result<Place, String> {
20 let parts: Vec<&str> = dotted.split('.').collect();
21 let Some(section) = parts
22 .first()
23 .and_then(|s| CATALOGUE.iter().find(|c| c.name == *s))
24 else {
25 if parts.first() == Some(&"ui") {
26 return Err("[ui] is a person's setting, not the project's: it belongs in ~/.config/speccoder/config.toml".to_owned());
27 }
28 return Err(format!(
29 "unknown section in `{dotted}` (known: {})",
30 CATALOGUE
31 .iter()
32 .map(|c| c.name)
33 .collect::<Vec<_>>()
34 .join(", ")
35 ));
36 };
37 let (tables, rest): (Vec<String>, &[&str]) = if section.named {
38 let Some(name) = parts.get(1) else {
39 return Err(format!(
40 "`{dotted}`: name the table, e.g. {}.<name>.<key>",
41 section.name
42 ));
43 };
44 if name.is_empty()
45 || !name
46 .chars()
47 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
48 {
49 return Err(format!(
50 "`{name}`: a table name is lowercase letters, digits, - and _"
51 ));
52 }
53 (
54 vec![section.name.to_owned(), (*name).to_owned()],
55 &parts[2..],
56 )
57 } else {
58 (vec![section.name.to_owned()], &parts[1..])
59 };
60 let wanted = rest.join(".");
61 let key = section
62 .keys
63 .iter()
64 .find(|k| k.key == wanted)
65 .ok_or_else(|| {
66 format!(
67 "unknown key `{dotted}` (in [{}]: {})",
68 section.name,
69 section
70 .keys
71 .iter()
72 .map(|k| k.key)
73 .collect::<Vec<_>>()
74 .join(", ")
75 )
76 })?;
77 let mut tables = tables;
78 let mut leaf = wanted.clone();
79 if let Some((sub, last)) = wanted.rsplit_once('.') {
80 tables.extend(sub.split('.').map(str::to_owned));
81 leaf = last.to_owned();
82 }
83 Ok(Place { tables, leaf, key })
84}
85
86pub(super) fn check(dotted: &str, kind: Kind, value: &Value) -> Result<(), String> {
89 let strings = |v: &Value| v.as_array().is_some_and(|a| a.iter().all(Value::is_string));
90 let ok = match kind {
91 Kind::Bool => value.is_boolean(),
92 Kind::Int(a, b) => value.as_i64().is_some_and(|n| (a..=b).contains(&n)),
93 Kind::Num(a, b) => value.as_f64().is_some_and(|n| n >= a && n <= b),
94 Kind::Str => value.as_str().is_some_and(|s| !s.is_empty()),
95 Kind::Choice(c) => value.as_str().is_some_and(|s| c.contains(&s)),
96 Kind::Duration => value.as_str().is_some_and(|s| {
97 let (n, u) = s.split_at(s.char_indices().last().map_or(0, |(i, _)| i));
99 n.parse::<u64>().is_ok_and(|n| n > 0) && ["m", "h", "d"].contains(&u)
100 }),
101 Kind::Argv => strings(value) && value.as_array().is_some_and(|a| !a.is_empty()),
102 Kind::ArgvList => value.as_array().is_some_and(|a| {
103 a.iter()
104 .all(|x| strings(x) && x.as_array().is_some_and(|i| !i.is_empty()))
105 }),
106 Kind::StrList => strings(value),
107 };
108 if ok {
109 return Ok(());
110 }
111 let want = match kind {
112 Kind::Bool => "true or false".to_owned(),
113 Kind::Int(a, b) => format!("a whole number from {a} to {b}"),
114 Kind::Num(a, b) => format!("a number from {a} to {b}"),
115 Kind::Str => "text".to_owned(),
116 Kind::Choice(c) => format!("one of {}", c.join(", ")),
117 Kind::Duration => "a duration like 30m, 12h or 7d".to_owned(),
118 Kind::Argv => "a program and its arguments, e.g. [\"cargo\", \"fmt\"]".to_owned(),
119 Kind::ArgvList => {
120 "a list of programs with arguments, e.g. [[\"cargo\", \"check\"]]".to_owned()
121 }
122 Kind::StrList => "a list of strings".to_owned(),
123 };
124 Err(format!("`{dotted}` = {value}: expected {want}"))
125}
126
127pub(super) fn toml_value(v: &Value) -> Option<toml_edit::Value> {
128 Some(match v {
129 Value::Bool(b) => toml_edit::Value::from(*b),
130 Value::Number(n) => match n.as_i64() {
131 Some(i) => toml_edit::Value::from(i),
132 None => toml_edit::Value::from(n.as_f64()?),
133 },
134 Value::String(s) => toml_edit::Value::from(s.as_str()),
135 Value::Array(a) => {
136 let mut arr = toml_edit::Array::new();
137 for x in a {
138 arr.push(toml_value(x)?);
139 }
140 toml_edit::Value::Array(arr)
141 }
142 Value::Null | Value::Object(_) => return None,
143 })
144}
145
146pub(super) fn table_mut<'a>(
150 doc: &'a mut toml_edit::DocumentMut,
151 path: &[String],
152 create: bool,
153) -> Result<Option<&'a mut dyn toml_edit::TableLike>, String> {
154 let mut t: &mut dyn toml_edit::TableLike = doc.as_table_mut();
155 let mut inline = false;
156 for (i, p) in path.iter().enumerate() {
157 if t.get(p).is_none() {
158 if !create {
159 return Ok(None);
160 }
161 let fresh = if inline {
162 toml_edit::Item::Value(toml_edit::Value::InlineTable(toml_edit::InlineTable::new()))
164 } else {
165 let mut table = toml_edit::Table::new();
166 let container = i == 0 && CATALOGUE.iter().any(|c| c.named && c.name == p);
167 table.set_implicit(container || i + 1 < path.len());
168 toml_edit::Item::Table(table)
169 };
170 t.insert(p, fresh);
171 }
172 let Some(item) = t.get_mut(p) else {
173 return Err(format!("`{}` could not be made", path[..=i].join(".")));
174 };
175 inline = inline || item.is_inline_table();
176 t = item
177 .as_table_like_mut()
178 .ok_or_else(|| format!("`{}` is not a table", path[..=i].join(".")))?;
179 }
180 Ok(Some(t))
181}
182
183pub(super) fn prune(doc: &mut toml_edit::DocumentMut, path: &[String]) {
186 for depth in (1..=path.len()).rev() {
187 let (parent, last) = (&path[..depth - 1], &path[depth - 1]);
188 let Ok(Some(t)) = table_mut(doc, parent, false) else {
189 return;
190 };
191 let empty = t
192 .get(last)
193 .and_then(toml_edit::Item::as_table_like)
194 .is_some_and(toml_edit::TableLike::is_empty);
195 if empty {
196 t.remove(last);
197 }
198 }
199}
200
201pub fn edit(text: &str, changes: &[ConfigChange]) -> Result<String, String> {
207 let mut doc: toml_edit::DocumentMut = text
208 .parse()
209 .map_err(|e: toml_edit::TomlError| e.to_string())?;
210 for c in changes {
211 let at = place(&c.key)?;
212 match (&c.value, &c.append) {
213 (Some(v), None) => {
214 check(&c.key, at.key.kind, v)?;
215 let value = toml_value(v)
216 .ok_or_else(|| format!("`{}`: a value TOML cannot hold", c.key))?;
217 let t = table_mut(&mut doc, &at.tables, true)?
218 .ok_or_else(|| format!("`{}` could not be made", c.key))?;
219 match t.get_mut(&at.leaf).and_then(toml_edit::Item::as_value_mut) {
220 Some(old) => {
222 let decor = old.decor().clone();
223 *old = value;
224 *old.decor_mut() = decor;
225 }
226 None => {
227 t.insert(&at.leaf, toml_edit::value(value));
228 }
229 }
230 }
231 (None, Some(item)) => {
232 if !matches!(at.key.kind, Kind::ArgvList | Kind::StrList) {
233 return Err(format!("`{}` is not a list", c.key));
234 }
235 let now: toml::Table = toml::from_str(&doc.to_string()).unwrap_or_default();
237 let mut path: Vec<&str> = at.tables.iter().map(String::as_str).collect();
238 path.push(&at.leaf);
239 let mut list: Vec<Value> = lookup(&now, &path)
240 .map(json_of)
241 .and_then(|v| v.as_array().cloned())
242 .unwrap_or_default();
243 if !list.contains(item) {
244 list.push(item.clone());
245 }
246 let all = Value::Array(list);
247 check(&c.key, at.key.kind, &all)?;
248 let value = toml_value(&all)
249 .ok_or_else(|| format!("`{}`: a value TOML cannot hold", c.key))?;
250 table_mut(&mut doc, &at.tables, true)?
251 .ok_or_else(|| format!("`{}` could not be made", c.key))?
252 .insert(&at.leaf, toml_edit::value(value));
253 }
254 (None, None) => {
255 if let Some(t) = table_mut(&mut doc, &at.tables, false)? {
257 t.remove(&at.leaf);
258 }
259 prune(&mut doc, &at.tables);
260 }
261 (Some(_), Some(_)) => {
262 return Err(format!(
263 "`{}`: set a value or append to it, not both",
264 c.key
265 ));
266 }
267 }
268 }
269 let out = doc.to_string();
270 match crate::substrate::model_config::parse(&out) {
271 Err(e) if e.starts_with(crate::substrate::model_config::NEEDS_REGION) => {}
275 other => {
276 other?;
277 }
278 }
279 validate_but_model(&out)?;
280 Ok(out)
281}
282
283pub fn value_of(key: &str, words: &[String]) -> Result<Value, String> {
291 let at = place(key)?;
292 let joined = words.join(" ");
293 if let Ok(v) = serde_json::from_str::<Value>(&joined)
294 && check(key, at.key.kind, &v).is_ok()
295 {
296 return Ok(v);
297 }
298 let v = match at.key.kind {
299 Kind::Bool => match joined.as_str() {
300 "true" | "on" | "yes" => json!(true),
301 "false" | "off" | "no" => json!(false),
302 _ => json!(joined),
303 },
304 Kind::Int(..) => joined
305 .parse::<i64>()
306 .map_or_else(|_| json!(joined), |n| json!(n)),
307 Kind::Num(..) => joined
308 .parse::<f64>()
309 .map_or_else(|_| json!(joined), |n| json!(n)),
310 Kind::Argv | Kind::StrList => json!(words),
311 Kind::ArgvList => json!([words]),
312 Kind::Str | Kind::Choice(_) | Kind::Duration => json!(joined),
313 };
314 check(key, at.key.kind, &v).map(|()| v)
315}