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