Skip to main content

speccoder_runtime/settings/
mod.rs

1//! `speccoder.toml` as settings: a catalogue of every key — its
2//! type, default and meaning — the values a project sets, and edits that
3//! keep the file's comments and are checked by every parser before they are
4//! written. speccoder reads committed configuration only, so a change is
5//! offered as a commit of exactly this file.
6
7use std::path::{Path, PathBuf};
8
9use serde_json::Value;
10use speccoder_core::{
11    CommandData, CommandResult, CommandStatus, ConfigChange, ConfigKey, ConfigReport,
12    ConfigSection, ConfigTable,
13};
14
15use crate::git::Git;
16use crate::verify::config::FILE;
17
18mod catalogue;
19mod edit;
20#[cfg(test)]
21mod tests;
22
23pub use catalogue::{CATALOGUE, Key, Kind, Section};
24pub use edit::{edit, value_of};
25
26/// Whether every parser accepts `text` — the same ones every command uses.
27///
28/// # Errors
29///
30/// The first parser's complaint.
31pub fn validate(text: &str) -> Result<(), String> {
32    crate::substrate::model_config::parse(text)?;
33    validate_but_model(text)
34}
35
36/// [`validate`] without `[model]`.
37pub(super) fn validate_but_model(text: &str) -> Result<(), String> {
38    crate::verify::config::parse(text)?;
39    crate::substrate::run_config::parse(text)?;
40    crate::sandbox::parse(text)?;
41    crate::prepare::parse(text)?;
42    crate::mcp::parse(text)?;
43    crate::mutation::parse(text)?;
44    crate::external::parse(text)?;
45    crate::format::parse_config(text)?;
46    Ok(())
47}
48
49/// The directory holding the configuration: the nearest with one, else the
50/// repository's top level, else `root`.
51pub(super) fn home(root: &Path) -> PathBuf {
52    // Where the file is, else where `init` would write it (the spec
53    // project's root, which in a nested project is not the repository's).
54    crate::verify::find_config(root).unwrap_or_else(|| crate::init::root_of(root))
55}
56
57pub(super) fn lookup<'a>(table: &'a toml::Table, path: &[&str]) -> Option<&'a toml::Value> {
58    let (last, parents) = path.split_last()?;
59    let mut t = table;
60    for p in parents {
61        t = t.get(*p)?.as_table()?;
62    }
63    t.get(*last)
64}
65
66pub(super) fn key_view(k: &Key, value: Option<Value>) -> ConfigKey {
67    let (min, max) = match k.kind {
68        #[allow(clippy::cast_precision_loss)]
69        Kind::Int(a, b) => (Some(a as f64), Some(b as f64)),
70        Kind::Num(a, b) => (Some(a), Some(b)),
71        _ => (None, None),
72    };
73    ConfigKey {
74        key: k.key.to_owned(),
75        kind: k.kind.name().to_owned(),
76        about: k.about.to_owned(),
77        default: k.default.and_then(|d| serde_json::from_str(d).ok()),
78        value,
79        choices: match k.kind {
80            Kind::Choice(c) => c.iter().map(|s| (*s).to_owned()).collect(),
81            _ => Vec::new(),
82        },
83        min,
84        max,
85        required: k.required,
86    }
87}
88
89pub(super) fn json_of(v: &toml::Value) -> Value {
90    serde_json::to_value(v).unwrap_or(Value::Null)
91}
92
93/// The configuration as settings: every key, set or default.
94#[must_use]
95pub fn read(root: &Path) -> ConfigReport {
96    let dir = home(root);
97    let path = dir.join(FILE);
98    let text = std::fs::read_to_string(&path).ok();
99    let table: toml::Table = text
100        .as_deref()
101        .and_then(|t| toml::from_str(t).ok())
102        .unwrap_or_default();
103    let sections = CATALOGUE
104        .iter()
105        .map(|s| {
106            if s.named {
107                let tables = table
108                    .get(s.name)
109                    .and_then(toml::Value::as_table)
110                    .map(|named| {
111                        named
112                            .iter()
113                            .map(|(name, t)| ConfigTable {
114                                name: name.clone(),
115                                keys: s
116                                    .keys
117                                    .iter()
118                                    .map(|k| {
119                                        let v =
120                                            t.as_table().and_then(|t| t.get(k.key)).map(json_of);
121                                        key_view(k, v)
122                                    })
123                                    .collect(),
124                            })
125                            .collect()
126                    })
127                    .unwrap_or_default();
128                ConfigSection {
129                    name: s.name.to_owned(),
130                    title: s.title.to_owned(),
131                    about: s.about.to_owned(),
132                    named: true,
133                    keys: s.keys.iter().map(|k| key_view(k, None)).collect(),
134                    tables,
135                }
136            } else {
137                ConfigSection {
138                    name: s.name.to_owned(),
139                    title: s.title.to_owned(),
140                    about: s.about.to_owned(),
141                    named: false,
142                    keys: s
143                        .keys
144                        .iter()
145                        .map(|k| {
146                            let mut path = vec![s.name];
147                            path.extend(k.key.split('.'));
148                            let mut view = key_view(k, lookup(&table, &path).map(json_of));
149                            // Bedrock's region is required with that provider.
150                            view.required |= s.name == "model"
151                                && k.key == "region"
152                                && lookup(&table, &["model", "provider"])
153                                    .and_then(toml::Value::as_str)
154                                    == Some("bedrock");
155                            view
156                        })
157                        .collect(),
158                    tables: Vec::new(),
159                }
160            }
161        })
162        .collect();
163    let error = text.as_deref().and_then(|t| validate(t).err());
164    let (committed, diff) = state(&dir, text.is_some());
165    ConfigReport {
166        file: FILE.to_owned(),
167        exists: text.is_some(),
168        committed,
169        valid: text.is_some() && error.is_none(),
170        error,
171        sections,
172        diff,
173    }
174}
175
176/// Whether the file on disk is the committed one, and the change if not.
177pub(super) fn state(dir: &Path, exists: bool) -> (bool, Option<String>) {
178    let g = Git::at(dir);
179    let tracked = g
180        .succeeds(&["ls-files", "--error-unmatch", "--", FILE])
181        .unwrap_or(false);
182    if !tracked {
183        let diff = exists.then(|| {
184            std::fs::read_to_string(dir.join(FILE))
185                .unwrap_or_default()
186                .lines()
187                .map(|l| format!("+{l}"))
188                .collect::<Vec<_>>()
189                .join("\n")
190        });
191        return (
192            false,
193            diff.map(|d| {
194                format!("diff --git a/{FILE} b/{FILE}\nnew file mode 100644\n@@ -0,0 +1 @@\n{d}\n")
195            }),
196        );
197    }
198    let clean = g
199        .succeeds(&["diff", "--quiet", "HEAD", "--", FILE])
200        .unwrap_or(false);
201    if clean {
202        return (true, None);
203    }
204    let diff = g
205        .run(&["diff", "--no-color", "HEAD", "--", FILE])
206        .map(|b| String::from_utf8_lossy(&b).into_owned())
207        .ok();
208    (false, diff)
209}
210
211/// What a change to `[model]` leaves to do: Bedrock's region when it is
212/// still missing (the step [`edit`] lets through), and a warning when a
213/// change made the model a Bedrock one with an id Bedrock does not use.
214fn model_notes(
215    text: &str,
216    changes: &[ConfigChange],
217) -> (Vec<String>, Vec<speccoder_core::next::NextStep>) {
218    use crate::substrate::model_config;
219    let mut notes = Vec::new();
220    let mut next = Vec::new();
221    if model_config::parse(text).is_err_and(|e| e.starts_with(model_config::NEEDS_REGION)) {
222        notes.push("provider bedrock needs model.region, set it next".to_owned());
223        next.push(speccoder_core::next::NextStep {
224            command: "speccoder config set model.region <region>".to_owned(),
225            why: "provider bedrock needs the AWS region its calls go to".to_owned(),
226        });
227    }
228    let touched = changes
229        .iter()
230        .any(|c| c.key == "model.provider" || c.key == "model.model");
231    let model: toml::Table = toml::from_str(text).unwrap_or_default();
232    let model = model.get("model").and_then(toml::Value::as_table);
233    let field = |k: &str| model.and_then(|m| m.get(k)).and_then(toml::Value::as_str);
234    if touched
235        && field("provider") == Some("bedrock")
236        && let Some(w) = field("model").and_then(model_config::bedrock_model_warning)
237    {
238        notes.push(format!("warning: {w}"));
239    }
240    (notes, next)
241}
242
243/// `speccoder config`: the settings.
244#[must_use]
245pub fn show(root: &Path) -> CommandResult {
246    let report = read(root);
247    let message = match (&report.exists, &report.error, report.committed) {
248        (false, _, _) => "no speccoder.toml yet: every key has its default".to_owned(),
249        (true, Some(e), _) => format!("speccoder.toml has an error: {e}"),
250        (true, None, false) => {
251            "speccoder.toml has changes that are not committed (not in force yet)".to_owned()
252        }
253        (true, None, true) => "speccoder.toml".to_owned(),
254    };
255    let result = CommandResult::new("config", CommandStatus::Ok, &message);
256    let result = if report.committed || !report.exists {
257        result
258    } else {
259        result.with_next(vec![speccoder_core::next::NextStep {
260            command: "speccoder config commit".to_owned(),
261            why: "speccoder reads committed configuration only".to_owned(),
262        }])
263    };
264    result.with_data(CommandData::Config(Box::new(report)))
265}
266
267/// The keys only a coverage preview's confirmation writes.
268pub const PREFIX_KEYS: [&str; 2] = ["commands.allow_prefix", "sandbox.network_allow_prefix"];
269
270/// `speccoder config set|unset`, and the page's save: applies `changes` to
271/// the file, all or nothing. A prefix key is refused: it is saved only as
272/// the confirmation of its coverage preview (`WidenPolicy`); removing an
273/// entry (`unset`) narrows and is allowed.
274#[must_use]
275pub fn apply(root: &Path, changes: &[ConfigChange]) -> CommandResult {
276    if let Some(c) = changes.iter().find(|c| {
277        PREFIX_KEYS.contains(&c.key.as_str()) && (c.value.is_some() || c.append.is_some())
278    }) {
279        // Setting the list to fewer entries than it has narrows it.
280        let narrows = c.value.as_ref().is_some_and(|v| {
281            let now = read(root);
282            let current = now
283                .sections
284                .iter()
285                .flat_map(|s| {
286                    s.keys
287                        .iter()
288                        .map(move |k| (format!("{}.{}", s.name, k.key), k))
289                })
290                .find(|(name, _)| *name == c.key)
291                .and_then(|(_, k)| k.value.clone())
292                .and_then(|v| v.as_array().cloned())
293                .unwrap_or_default();
294            v.as_array()
295                .is_some_and(|new| new.iter().all(|e| current.contains(e)))
296        });
297        if !narrows {
298            return CommandResult::new(
299                "config",
300                CommandStatus::UsageError,
301                &format!(
302                    "`{}` is saved only from its coverage preview: `speccoder config coverage {} <argv…>`, then confirm",
303                    c.key, c.key
304                ),
305            )
306            .with_code("CONFIG-INVALID");
307        }
308    }
309    apply_confirmed(root, changes)
310}
311
312/// [`apply`] without the prefix guard: for the confirmation of a coverage
313/// preview and for Always Allow's exact entry, which their actions checked.
314#[must_use]
315pub(crate) fn apply_confirmed(root: &Path, changes: &[ConfigChange]) -> CommandResult {
316    let dir = home(root);
317    let path = dir.join(FILE);
318    let text = std::fs::read_to_string(&path).unwrap_or_default();
319    match edit(&text, changes) {
320        Err(e) => CommandResult::new("config", CommandStatus::ConfigError, &e)
321            .with_code("CONFIG-INVALID")
322            .with_fix(vec![
323                crate::substrate::model_config::config_fix(&e).unwrap_or_else(|| {
324                    format!(
325                        "speccoder config get {}",
326                        changes.first().map_or("", |c| c.key.as_str())
327                    )
328                }),
329            ]),
330        Ok(out) if out == text => show(root),
331        Ok(out) => match std::fs::write(&path, &out) {
332            Ok(()) => {
333                let keys: Vec<&str> = changes.iter().map(|c| c.key.as_str()).collect();
334                let r = show(root);
335                let (notes, mut next) = model_notes(&out, changes);
336                next.push(speccoder_core::next::NextStep {
337                    command: "speccoder config commit".to_owned(),
338                    why: "speccoder reads committed configuration only".to_owned(),
339                });
340                let mut message =
341                    format!("changed {} — commit it to put it in force", keys.join(", "));
342                for n in notes {
343                    message.push('\n');
344                    message.push_str(&n);
345                }
346                CommandResult::new("config", CommandStatus::Ok, &message)
347                    .with_next(next)
348                    .with_data(
349                        r.data()
350                            .cloned()
351                            .unwrap_or(CommandData::Config(Box::new(read(root)))),
352                    )
353            }
354            Err(e) => CommandResult::new(
355                "config",
356                CommandStatus::Error,
357                &format!("{}: {e}", path.display()),
358            ),
359        },
360    }
361}
362
363/// `speccoder config commit`: commits exactly `speccoder.toml`, nothing else
364/// staged, once every parser accepts it.
365#[must_use]
366pub fn commit(root: &Path) -> CommandResult {
367    let dir = home(root);
368    let text = match std::fs::read_to_string(dir.join(FILE)) {
369        Ok(t) => t,
370        Err(_) => {
371            return CommandResult::new(
372                "config",
373                CommandStatus::ConfigError,
374                "there is no speccoder.toml to commit",
375            )
376            .with_code("CONFIG-MISSING");
377        }
378    };
379    if let Err(e) = validate(&text) {
380        return CommandResult::new("config", CommandStatus::ConfigError, &e)
381            .with_code("CONFIG-INVALID");
382    }
383    let (committed, _) = state(&dir, true);
384    if committed {
385        return CommandResult::new(
386            "config",
387            CommandStatus::Ok,
388            "speccoder.toml is committed; nothing to do",
389        );
390    }
391    // Which sections changed, for the message.
392    let g = Git::at(&dir);
393    let before: toml::Table = g
394        .run(&["show", &format!("HEAD:./{FILE}")])
395        .ok()
396        .and_then(|b| toml::from_str(&String::from_utf8_lossy(&b)).ok())
397        .unwrap_or_default();
398    let after: toml::Table = toml::from_str(&text).unwrap_or_default();
399    let mut changed: Vec<&str> = CATALOGUE
400        .iter()
401        .map(|s| s.name)
402        .filter(|s| before.get(*s) != after.get(*s))
403        .collect();
404    changed.dedup();
405    let message = if changed.is_empty() {
406        "speccoder: configuration".to_owned()
407    } else {
408        format!("speccoder: configuration ({})", changed.join(", "))
409    };
410    match g
411        .run(&["add", "--", FILE])
412        .and_then(|_| g.run(&["commit", "-q", "-m", &message, "--", FILE]))
413    {
414        Ok(_) => {
415            let id = g
416                .line(&["rev-parse", "--short", "HEAD"])
417                .unwrap_or_default();
418            CommandResult::new(
419                "config",
420                CommandStatus::Ok,
421                &format!("committed speccoder.toml as {id} \"{message}\""),
422            )
423            .with_data(CommandData::Config(Box::new(read(root))))
424        }
425        Err(e) => CommandResult::new(
426            "config",
427            CommandStatus::GitPrecondition,
428            &format!("commit failed: {}", e.0),
429        ),
430    }
431}