Skip to main content

speccoder_runtime/settings/
edit.rs

1//! Edits of `speccoder.toml` that keep its comments: a dotted key's place,
2//! its value checked against its kind, the change checked by every parser.
3
4use 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
11/// Where a dotted key lives: the table path and the leaf, with its catalogue
12/// entry.
13pub(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
86/// Checks `value` against `kind` before any parser sees it, for a message
87/// that names the key.
88pub(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            // The unit is the last character, which may be more than a byte.
98            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
146/// The table at `path` — a `[table]` or an inline `{ … }` — made when
147/// missing and `create`; `None` when missing and not. The containers of
148/// named tables (`[checks]`, `[mcp]`) are implicit: no header of their own.
149pub(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                // Inside an inline table, what is made is inline too.
163                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
183/// Removes empty tables (or inline tables) left behind by an unset,
184/// innermost first.
185pub(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
201/// The new text with `changes` applied, or why not.
202///
203/// # Errors
204///
205/// An unknown key, a value of the wrong kind, or a result a parser refuses.
206pub 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                    // Keep the key's own comment and spacing.
221                    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                // The list as it is now, read back through the parser.
236                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                // Missing is already unset; anything else in the way is an error.
256                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        // A step on the way to Bedrock: its provider before its region,
272        // which `config set model.region` adds next (until then every
273        // command, and `config commit`, refuses the file and says so).
274        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
283/// A value for `key` from the words of `config set KEY WORDS…`: JSON when it
284/// parses as the key's kind, else words read by the kind (`cargo fmt` for an
285/// argv, `40` for a number, `on`/`off` for a switch).
286///
287/// # Errors
288///
289/// An unknown key, or words that are no value of its kind.
290pub 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}