Skip to main content

speccoder_runtime/
settings.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, json};
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
18/// What a key holds.
19#[derive(Debug, Clone, Copy, PartialEq)]
20pub enum Kind {
21    /// `true` or `false`.
22    Bool,
23    /// A whole number in a range.
24    Int(i64, i64),
25    /// A number in a range.
26    Num(f64, f64),
27    /// Text.
28    Str,
29    /// One of these.
30    Choice(&'static [&'static str]),
31    /// `30m`, `12h`, `7d`.
32    Duration,
33    /// A program and its arguments.
34    Argv,
35    /// A list of programs with arguments.
36    ArgvList,
37    /// A list of strings.
38    StrList,
39}
40
41impl Kind {
42    const fn name(self) -> &'static str {
43        match self {
44            Self::Bool => "bool",
45            Self::Int(..) => "integer",
46            Self::Num(..) => "number",
47            Self::Str => "string",
48            Self::Choice(_) => "choice",
49            Self::Duration => "duration",
50            Self::Argv => "argv",
51            Self::ArgvList => "argv_list",
52            Self::StrList => "string_list",
53        }
54    }
55}
56
57/// One key of the catalogue.
58#[derive(Debug, Clone, Copy)]
59pub struct Key {
60    /// The key, dotted below a sub-table (`sigstore.identity`).
61    pub key: &'static str,
62    /// What it holds.
63    pub kind: Kind,
64    /// Its default as JSON, when it has one.
65    pub default: Option<&'static str>,
66    /// The section is refused without it.
67    pub required: bool,
68    /// What it does.
69    pub about: &'static str,
70}
71
72/// One section of the catalogue.
73#[derive(Debug, Clone, Copy)]
74pub struct Section {
75    /// The table name.
76    pub name: &'static str,
77    /// For people.
78    pub title: &'static str,
79    /// What it decides.
80    pub about: &'static str,
81    /// `[name.<table>]` sections.
82    pub named: bool,
83    /// Its keys.
84    pub keys: &'static [Key],
85}
86
87const fn k(
88    key: &'static str,
89    kind: Kind,
90    default: Option<&'static str>,
91    about: &'static str,
92) -> Key {
93    Key {
94        key,
95        kind,
96        default,
97        required: false,
98        about,
99    }
100}
101
102const fn req(key: &'static str, kind: Kind, about: &'static str) -> Key {
103    Key {
104        key,
105        kind,
106        default: None,
107        required: true,
108        about,
109    }
110}
111
112/// The spec formats, by the names `[spec] format` takes — from the format
113/// model, not spelled here.
114const FORMATS: &[&str] = &[
115    speccoder_core::spec::FormatKind::ALL[0].name(),
116    speccoder_core::spec::FormatKind::ALL[1].name(),
117    speccoder_core::spec::FormatKind::ALL[2].name(),
118];
119
120const PRICE: Kind = Kind::Num(0.0, 100_000.0);
121
122/// Every key of `speccoder.toml`, in the reference's order. A test holds it
123/// to the parsers and to the configuration reference.
124pub const CATALOGUE: &[Section] = &[
125    Section {
126        name: "model",
127        title: "Model",
128        about: "The model that makes the attempts. The key comes from ANTHROPIC_API_KEY or OPENAI_API_KEY, Bedrock's credentials from AWS — never from this file.",
129        named: false,
130        keys: &[
131            req(
132                "provider",
133                Kind::Choice(&["anthropic", "openai", "openai-compatible", "bedrock"]),
134                "Who serves the model; openai-compatible covers Ollama, vLLM, llama.cpp, LM Studio and others; bedrock is Amazon Bedrock, credentials from AWS",
135            ),
136            req(
137                "model",
138                Kind::Str,
139                "The model, for example claude-sonnet-5-5",
140            ),
141            k(
142                "region",
143                Kind::Str,
144                None,
145                "Bedrock only: the AWS region calls go to (the journal records it)",
146            ),
147            k(
148                "reasoning_summary",
149                Kind::Bool,
150                Some("true"),
151                "Show the model's summary of its reasoning (a claim, not evidence)",
152            ),
153            k(
154                "max_output_tokens",
155                Kind::Int(1, 1_000_000),
156                Some("4096"),
157                "Output tokens per model turn",
158            ),
159            k(
160                "max_run_tokens",
161                Kind::Int(1, 100_000_000),
162                Some("500000"),
163                "Tokens per attempt",
164            ),
165            k(
166                "cache_ttl",
167                Kind::Choice(&["5m", "1h"]),
168                Some("\"5m\""),
169                "Anthropic's prompt-cache lifetime",
170            ),
171            k(
172                "base_url",
173                Kind::Str,
174                None,
175                "The server, required for openai-compatible (e.g. http://localhost:11434/v1)",
176            ),
177            k(
178                "api_key_env",
179                Kind::Str,
180                None,
181                "openai-compatible only: the variable holding the server's key",
182            ),
183            k(
184                "structured",
185                Kind::Choice(&["native", "tool"]),
186                Some("\"native\""),
187                "openai-compatible only: native structured output, or a forced tool call",
188            ),
189            k(
190                "price_input_per_mtok",
191                PRICE,
192                None,
193                "USD per million input tokens, for cost reports",
194            ),
195            k(
196                "price_output_per_mtok",
197                PRICE,
198                None,
199                "USD per million output tokens",
200            ),
201            k(
202                "price_cache_read_per_mtok",
203                PRICE,
204                None,
205                "USD per million tokens read from the cache",
206            ),
207            k(
208                "price_cache_write_per_mtok",
209                PRICE,
210                None,
211                "USD per million tokens written to the five-minute cache",
212            ),
213            k(
214                "price_cache_write_1h_per_mtok",
215                PRICE,
216                None,
217                "USD per million tokens written to the one-hour cache",
218            ),
219        ],
220    },
221    Section {
222        name: "checks",
223        title: "Checks",
224        about: "The commands that decide whether nothing regressed. Each check's command up to its first option is also something the model may run.",
225        named: true,
226        keys: &[
227            req(
228                "argv",
229                Kind::Argv,
230                "Program and arguments, run without a shell",
231            ),
232            k(
233                "cwd",
234                Kind::Str,
235                Some("\".\""),
236                "Working directory, relative to the project root",
237            ),
238            k(
239                "when_exists",
240                Kind::Str,
241                None,
242                "Run only if this path exists; otherwise skipped, never passed",
243            ),
244            k(
245                "tier",
246                Kind::Choice(&["fast", "full"]),
247                Some("\"fast\""),
248                "fast judges every task; full runs once after the feature's last task",
249            ),
250            k(
251                "timeout_secs",
252                Kind::Int(1, 86_400),
253                Some("600"),
254                "Deadline for the whole process group",
255            ),
256            k(
257                "junit",
258                Kind::Str,
259                None,
260                "A JUnit XML report the check writes, for per-test results",
261            ),
262            k(
263                "list",
264                Kind::Argv,
265                None,
266                "A command that lists the tests the check would run (one test id or file path per line); speccoder runs it when it cannot otherwise tell",
267            ),
268        ],
269    },
270    Section {
271        name: "budget",
272        title: "Budget",
273        about: "How much an attempt may do, and how often a task is tried.",
274        named: false,
275        keys: &[
276            k(
277                "max_turns",
278                Kind::Int(1, 1000),
279                Some("30"),
280                "Model turns per attempt",
281            ),
282            k(
283                "max_tool_calls",
284                Kind::Int(1, 10_000),
285                Some("100"),
286                "Tool calls per attempt",
287            ),
288            k(
289                "max_attempts",
290                Kind::Int(1, 20),
291                Some("2"),
292                "Attempts per task run; a failed verdict is retried with the verifier's facts",
293            ),
294            k(
295                "best_of",
296                Kind::Int(1, 10),
297                Some("1"),
298                "Independent samples per task, the best by evidence kept",
299            ),
300        ],
301    },
302    Section {
303        name: "commands",
304        title: "Commands",
305        about: "What the model may run without asking, besides the checks. `allow` is exact; a prefix in `allow_prefix` runs whatever follows it, so it is saved only from a preview of what it would newly allow.",
306        named: false,
307        keys: &[
308            k(
309                "allow",
310                Kind::ArgvList,
311                Some("[]"),
312                "Exact commands that run without asking, e.g. [\"cargo\", \"check\"]; nothing after them",
313            ),
314            k(
315                "allow_prefix",
316                Kind::ArgvList,
317                Some("[]"),
318                "Command prefixes (two elements or more) whose every extension runs without asking; saved only from the coverage preview",
319            ),
320        ],
321    },
322    Section {
323        name: "approvals",
324        title: "Approvals",
325        about: "What runs without asking, how long a call waits for a person, and how often a run may ask.",
326        named: false,
327        keys: &[
328            k(
329                "contained",
330                Kind::Choice(&["allow", "ask"]),
331                Some("\"allow\""),
332                "Commands the OS sandbox confines (writes only here, no network beyond loopback) run without asking; \"ask\" asks for each",
333            ),
334            k(
335                "wait",
336                Kind::Duration,
337                Some("\"7d\""),
338                "How long a call waits before it is denied as expired (m, h or d)",
339            ),
340            k(
341                "budget",
342                Kind::Int(0, 100),
343                Some("12"),
344                "Prompts a run shows you; further calls wait together in Needs You",
345            ),
346        ],
347    },
348    Section {
349        name: "tests",
350        title: "Tests",
351        about: "What the model may do with tests that existed before an attempt.",
352        named: false,
353        keys: &[k(
354            "frozen",
355            Kind::Choice(&["ask", "additions"]),
356            Some("\"ask\""),
357            "ask before any change, or allow new lines without asking",
358        )],
359    },
360    Section {
361        name: "spec",
362        title: "Spec format",
363        about: "Which spec format the project uses; detected when unset.",
364        named: false,
365        keys: &[k(
366            "format",
367            Kind::Choice(FORMATS),
368            None,
369            "The spec format; --format and SPECCODER_FORMAT override it",
370        )],
371    },
372    Section {
373        name: "context",
374        title: "Instructions",
375        about: "The repository's instructions for agents, given to every attempt.",
376        named: false,
377        keys: &[k(
378            "instructions",
379            Kind::StrList,
380            Some("[\"AGENTS.md\"]"),
381            "File names read per directory, the first that exists; none to read none",
382        )],
383    },
384    Section {
385        name: "acceptance",
386        title: "Acceptance",
387        about: "Tests derived from the spec, and how strong they are.",
388        named: false,
389        keys: &[
390            k(
391                "generate",
392                Kind::Bool,
393                Some("true"),
394                "Derive acceptance tests from the spec: a story's oracle before its first code task, a convergence gap's own test",
395            ),
396            k(
397                "held_out",
398                Kind::Bool,
399                Some("true"),
400                "Also write held-out tests the implementer never sees; a story's last code task needs them to pass",
401            ),
402            k(
403                "strength",
404                Kind::Bool,
405                Some("true"),
406                "Measure the acceptance strength of every verified task",
407            ),
408            k(
409                "min_strength",
410                Kind::Num(0.0, 1.0),
411                None,
412                "Below it the set is strengthened once, then the task stops for you",
413            ),
414            k(
415                "near_miss",
416                Kind::Int(0, 3),
417                Some("2"),
418                "Wrong variants to try",
419            ),
420            k(
421                "permitted",
422                Kind::Int(0, 2),
423                Some("1"),
424                "Spec-permitted alternatives to try; 0 turns the over-strict check off",
425            ),
426            k(
427                "strength_timeout_secs",
428                Kind::Int(1, 86_400),
429                Some("900"),
430                "Budget for the whole strength measurement",
431            ),
432        ],
433    },
434    Section {
435        name: "bug",
436        title: "Bug fixes",
437        about: "`speccoder bug`: how hard reproduce tries before the bug goes to you as cannot reproduce. A project setting only: a value in your user config is ignored.",
438        named: false,
439        keys: &[
440            k(
441                "reproduce_tries",
442                Kind::Int(1, 5),
443                Some("3"),
444                "Tries of the reproduction author before cannot reproduce (a rejection counts as one)",
445            ),
446            k(
447                "test",
448                Kind::Argv,
449                None,
450                "How one named test (`--test PATH::NAME`) runs, `{file}` and `{name}` filled in; default: the toolchain's own runner",
451            ),
452        ],
453    },
454    Section {
455        name: "ask",
456        title: "Ask",
457        about: "The read-only conversation beside every view: its model and its limits.",
458        named: false,
459        keys: &[
460            k(
461                "model",
462                Kind::Str,
463                None,
464                "Ask's model; [model]'s when unset (same provider)",
465            ),
466            k(
467                "max_turn_tokens",
468                Kind::Int(1_000, 10_000_000),
469                Some("100000"),
470                "Tokens one question may use, cache reads apart",
471            ),
472            k(
473                "daily_tokens",
474                Kind::Int(0, 1_000_000_000),
475                Some("1000000"),
476                "Tokens Ask may use in a UTC day; runs are never counted",
477            ),
478        ],
479    },
480    Section {
481        name: "ladder",
482        title: "Promotion",
483        about: "When a quick change or bug that grew is offered as a spec of its own.",
484        named: false,
485        keys: &[k(
486            "promote_files",
487            Kind::Int(1, 10_000),
488            Some("8"),
489            "More files changed than this offers promotion",
490        )],
491    },
492    Section {
493        name: "converge",
494        title: "Convergence",
495        about: "How often a feature may be brought back to its spec.",
496        named: false,
497        keys: &[k(
498            "max_rounds",
499            Kind::Int(1, 20),
500            Some("3"),
501            "Convergence phases a feature may get",
502        )],
503    },
504    Section {
505        name: "review",
506        title: "Spec review",
507        about: "Whether a run waits for every requirement to be approved, and how tests are written at review.",
508        named: false,
509        keys: &[
510            k(
511                "required",
512                Kind::Bool,
513                Some("false"),
514                "A run waits until every requirement is approved on its current wording",
515            ),
516            k(
517                "oracle_samples",
518                Kind::Int(1, 2),
519                Some("2"),
520                "Independent author calls Write Tests makes; two are compared for ambiguity",
521            ),
522        ],
523    },
524    Section {
525        name: "monitor",
526        title: "Gaming monitor",
527        about: "A second model that reviews every passing attempt for gaming.",
528        named: false,
529        keys: &[k(
530            "enabled",
531            Kind::Bool,
532            Some("false"),
533            "Review every passing attempt",
534        )],
535    },
536    Section {
537        name: "git",
538        title: "Git",
539        about: "How passed tasks are recorded.",
540        named: false,
541        keys: &[k(
542            "commit",
543            Kind::Choice(&["per-task", "none"]),
544            Some("\"per-task\""),
545            "Commit each passed task, or leave changes uncommitted",
546        )],
547    },
548    Section {
549        name: "format",
550        title: "Formatter",
551        about: "A formatter run after each attempt; its changes are part of the attempt and audited.",
552        named: false,
553        keys: &[k(
554            "argv",
555            Kind::Argv,
556            None,
557            "The formatter, e.g. [\"cargo\", \"fmt\"]",
558        )],
559    },
560    Section {
561        name: "sandbox",
562        title: "Sandbox",
563        about: "Where commands the model runs may write and connect.",
564        named: false,
565        keys: &[
566            k(
567                "mode",
568                Kind::Choice(&["auto", "required", "off"]),
569                Some("\"auto\""),
570                "auto sandboxes where the OS can; required refuses to run unsandboxed",
571            ),
572            k(
573                "network",
574                Kind::Choice(&["off", "loopback", "on"]),
575                Some("\"loopback\""),
576                "Network for commands; loopback lets local test servers work",
577            ),
578            k(
579                "network_allow",
580                Kind::ArgvList,
581                Some("[]"),
582                "Exact commands that get the network, e.g. [\"cargo\", \"fetch\"]",
583            ),
584            k(
585                "network_allow_prefix",
586                Kind::ArgvList,
587                Some("[]"),
588                "Prefixes (two elements or more) whose extensions get the network; saved only from the coverage preview",
589            ),
590            k(
591                "writable",
592                Kind::StrList,
593                Some("[]"),
594                "Extra writable paths (~ is your home), e.g. ~/.m2",
595            ),
596        ],
597    },
598    Section {
599        name: "worktree",
600        title: "Worktree",
601        about: "What a run's worktree gets that git never holds: dependency folders (node_modules, .venv) and git-ignored files such as .env.",
602        named: false,
603        keys: &[
604            k(
605                "prepare",
606                Kind::Choice(&["auto", "off"]),
607                Some("\"auto\""),
608                "auto installs Node and uv dependencies (locked, offline) before the baseline; off never",
609            ),
610            k(
611                "include",
612                Kind::StrList,
613                Some("[]"),
614                "Git-ignored files copied from your checkout, .gitignore patterns, e.g. .env (with .worktreeinclude's); never committed, secret-named values masked",
615            ),
616        ],
617    },
618    Section {
619        name: "attest",
620        title: "Attestations",
621        about: "Signed statements of every tick, as git notes.",
622        named: false,
623        keys: &[
624            k(
625                "enabled",
626                Kind::Bool,
627                Some("true"),
628                "Write the statements on each tick commit",
629            ),
630            k(
631                "signer",
632                Kind::Choice(&["git", "sigstore", "none"]),
633                Some("\"git\""),
634                "Your git signing key, keyless sigstore, or unsigned",
635            ),
636            k(
637                "sign_at",
638                Kind::Choice(&["tick", "later"]),
639                Some("\"tick\""),
640                "Sign at each tick, or later with speccoder attest sign",
641            ),
642            k(
643                "sigstore.identity",
644                Kind::Str,
645                None,
646                "The identity you sign with, suggested to reviewers",
647            ),
648            k("sigstore.issuer", Kind::Str, None, "Its OIDC issuer"),
649        ],
650    },
651    Section {
652        name: "mutation",
653        title: "Mutation testing",
654        about: "Mutants run against the suite after a passing verdict.",
655        named: false,
656        keys: &[
657            req(
658                "tool",
659                Kind::Choice(&["cargo-mutants", "command"]),
660                "cargo-mutants, or command for any tool writing the mutation-testing report",
661            ),
662            k(
663                "argv",
664                Kind::Argv,
665                None,
666                "For command: the program; may use {diff}, {out}, {files}, {files_csv}",
667            ),
668            k("report", Kind::Str, None, "For command: the report file"),
669            k(
670                "mode",
671                Kind::Choice(&["report", "require"]),
672                Some("\"report\""),
673                "report records; require fails the verdict when mutants survive",
674            ),
675            k(
676                "timeout_secs",
677                Kind::Int(1, 86_400),
678                Some("900"),
679                "Limit for the whole mutation run",
680            ),
681            k(
682                "suite",
683                Kind::Bool,
684                Some("true"),
685                "Run mutants against the whole suite; false uses them for strength only",
686            ),
687            k(
688                "acceptance_argv",
689                Kind::Argv,
690                None,
691                "For command: runs the mutants against the acceptance tests alone",
692            ),
693        ],
694    },
695    Section {
696        name: "executor",
697        title: "Executor",
698        about: "Who makes the attempts: speccoder's own loop, or an external agent over ACP whose calls speccoder decides.",
699        named: false,
700        keys: &[
701            k(
702                "agent",
703                Kind::Choice(&["copilot", "opencode", "claude", "codex", "gemini"]),
704                None,
705                "GitHub Copilot, OpenCode, Claude Code, Codex or Gemini CLI, over ACP",
706            ),
707            k(
708                "kind",
709                Kind::Choice(&["native", "acp", "command"]),
710                None,
711                "Without an agent: native, any ACP agent (acp) or any command, by argv",
712            ),
713            k(
714                "model",
715                Kind::Str,
716                None,
717                "The agent's model, where it offers a choice",
718            ),
719            k(
720                "mode",
721                Kind::Str,
722                None,
723                "The agent's own permission mode; unset, speccoder asks for the one that asks first",
724            ),
725            k(
726                "timeout_secs",
727                Kind::Int(1, 86_400),
728                Some("1800"),
729                "How long one attempt may take",
730            ),
731            k(
732                "argv",
733                Kind::Argv,
734                None,
735                "For acp: the agent's command; for command: the program, which reads the task on stdin",
736            ),
737        ],
738    },
739    Section {
740        name: "mcp",
741        title: "MCP servers",
742        about: "Stdio MCP servers started in the worktree for each task; their tools appear as mcp__<server>__<tool>.",
743        named: true,
744        keys: &[
745            req("command", Kind::Argv, "The server's program and arguments"),
746            k(
747                "allow",
748                Kind::StrList,
749                Some("[]"),
750                "Tools called without approval",
751            ),
752            k("deny", Kind::StrList, Some("[]"), "Tools never offered"),
753        ],
754    },
755];
756
757/// Whether every parser accepts `text` — the same ones every command uses.
758///
759/// # Errors
760///
761/// The first parser's complaint.
762pub fn validate(text: &str) -> Result<(), String> {
763    crate::substrate::model_config::parse(text)?;
764    validate_but_model(text)
765}
766
767/// [`validate`] without `[model]`.
768fn validate_but_model(text: &str) -> Result<(), String> {
769    crate::verify::config::parse(text)?;
770    crate::substrate::run_config::parse(text)?;
771    crate::sandbox::parse(text)?;
772    crate::prepare::parse(text)?;
773    crate::mcp::parse(text)?;
774    crate::mutation::parse(text)?;
775    crate::external::parse(text)?;
776    crate::format::parse_config(text)?;
777    Ok(())
778}
779
780/// The directory holding the configuration: the nearest with one, else the
781/// repository's top level, else `root`.
782fn home(root: &Path) -> PathBuf {
783    // Where the file is, else where `init` would write it (the spec
784    // project's root, which in a nested project is not the repository's).
785    crate::verify::find_config(root).unwrap_or_else(|| crate::init::root_of(root))
786}
787
788fn lookup<'a>(table: &'a toml::Table, path: &[&str]) -> Option<&'a toml::Value> {
789    let (last, parents) = path.split_last()?;
790    let mut t = table;
791    for p in parents {
792        t = t.get(*p)?.as_table()?;
793    }
794    t.get(*last)
795}
796
797fn key_view(k: &Key, value: Option<Value>) -> ConfigKey {
798    let (min, max) = match k.kind {
799        #[allow(clippy::cast_precision_loss)]
800        Kind::Int(a, b) => (Some(a as f64), Some(b as f64)),
801        Kind::Num(a, b) => (Some(a), Some(b)),
802        _ => (None, None),
803    };
804    ConfigKey {
805        key: k.key.to_owned(),
806        kind: k.kind.name().to_owned(),
807        about: k.about.to_owned(),
808        default: k.default.and_then(|d| serde_json::from_str(d).ok()),
809        value,
810        choices: match k.kind {
811            Kind::Choice(c) => c.iter().map(|s| (*s).to_owned()).collect(),
812            _ => Vec::new(),
813        },
814        min,
815        max,
816        required: k.required,
817    }
818}
819
820fn json_of(v: &toml::Value) -> Value {
821    serde_json::to_value(v).unwrap_or(Value::Null)
822}
823
824/// The configuration as settings: every key, set or default.
825#[must_use]
826pub fn read(root: &Path) -> ConfigReport {
827    let dir = home(root);
828    let path = dir.join(FILE);
829    let text = std::fs::read_to_string(&path).ok();
830    let table: toml::Table = text
831        .as_deref()
832        .and_then(|t| toml::from_str(t).ok())
833        .unwrap_or_default();
834    let sections = CATALOGUE
835        .iter()
836        .map(|s| {
837            if s.named {
838                let tables = table
839                    .get(s.name)
840                    .and_then(toml::Value::as_table)
841                    .map(|named| {
842                        named
843                            .iter()
844                            .map(|(name, t)| ConfigTable {
845                                name: name.clone(),
846                                keys: s
847                                    .keys
848                                    .iter()
849                                    .map(|k| {
850                                        let v =
851                                            t.as_table().and_then(|t| t.get(k.key)).map(json_of);
852                                        key_view(k, v)
853                                    })
854                                    .collect(),
855                            })
856                            .collect()
857                    })
858                    .unwrap_or_default();
859                ConfigSection {
860                    name: s.name.to_owned(),
861                    title: s.title.to_owned(),
862                    about: s.about.to_owned(),
863                    named: true,
864                    keys: s.keys.iter().map(|k| key_view(k, None)).collect(),
865                    tables,
866                }
867            } else {
868                ConfigSection {
869                    name: s.name.to_owned(),
870                    title: s.title.to_owned(),
871                    about: s.about.to_owned(),
872                    named: false,
873                    keys: s
874                        .keys
875                        .iter()
876                        .map(|k| {
877                            let mut path = vec![s.name];
878                            path.extend(k.key.split('.'));
879                            let mut view = key_view(k, lookup(&table, &path).map(json_of));
880                            // Bedrock's region is required with that provider.
881                            view.required |= s.name == "model"
882                                && k.key == "region"
883                                && lookup(&table, &["model", "provider"])
884                                    .and_then(toml::Value::as_str)
885                                    == Some("bedrock");
886                            view
887                        })
888                        .collect(),
889                    tables: Vec::new(),
890                }
891            }
892        })
893        .collect();
894    let error = text.as_deref().and_then(|t| validate(t).err());
895    let (committed, diff) = state(&dir, text.is_some());
896    ConfigReport {
897        file: FILE.to_owned(),
898        exists: text.is_some(),
899        committed,
900        valid: text.is_some() && error.is_none(),
901        error,
902        sections,
903        diff,
904    }
905}
906
907/// Whether the file on disk is the committed one, and the change if not.
908fn state(dir: &Path, exists: bool) -> (bool, Option<String>) {
909    let g = Git::at(dir);
910    let tracked = g
911        .succeeds(&["ls-files", "--error-unmatch", "--", FILE])
912        .unwrap_or(false);
913    if !tracked {
914        let diff = exists.then(|| {
915            std::fs::read_to_string(dir.join(FILE))
916                .unwrap_or_default()
917                .lines()
918                .map(|l| format!("+{l}"))
919                .collect::<Vec<_>>()
920                .join("\n")
921        });
922        return (
923            false,
924            diff.map(|d| {
925                format!("diff --git a/{FILE} b/{FILE}\nnew file mode 100644\n@@ -0,0 +1 @@\n{d}\n")
926            }),
927        );
928    }
929    let clean = g
930        .succeeds(&["diff", "--quiet", "HEAD", "--", FILE])
931        .unwrap_or(false);
932    if clean {
933        return (true, None);
934    }
935    let diff = g
936        .run(&["diff", "--no-color", "HEAD", "--", FILE])
937        .map(|b| String::from_utf8_lossy(&b).into_owned())
938        .ok();
939    (false, diff)
940}
941
942/// Where a dotted key lives: the table path and the leaf, with its catalogue
943/// entry.
944struct Place {
945    tables: Vec<String>,
946    leaf: String,
947    key: &'static Key,
948}
949
950fn place(dotted: &str) -> Result<Place, String> {
951    let parts: Vec<&str> = dotted.split('.').collect();
952    let Some(section) = parts
953        .first()
954        .and_then(|s| CATALOGUE.iter().find(|c| c.name == *s))
955    else {
956        if parts.first() == Some(&"ui") {
957            return Err("[ui] is a person's setting, not the project's: it belongs in ~/.config/speccoder/config.toml".to_owned());
958        }
959        return Err(format!(
960            "unknown section in `{dotted}` (known: {})",
961            CATALOGUE
962                .iter()
963                .map(|c| c.name)
964                .collect::<Vec<_>>()
965                .join(", ")
966        ));
967    };
968    let (tables, rest): (Vec<String>, &[&str]) = if section.named {
969        let Some(name) = parts.get(1) else {
970            return Err(format!(
971                "`{dotted}`: name the table, e.g. {}.<name>.<key>",
972                section.name
973            ));
974        };
975        if name.is_empty()
976            || !name
977                .chars()
978                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
979        {
980            return Err(format!(
981                "`{name}`: a table name is lowercase letters, digits, - and _"
982            ));
983        }
984        (
985            vec![section.name.to_owned(), (*name).to_owned()],
986            &parts[2..],
987        )
988    } else {
989        (vec![section.name.to_owned()], &parts[1..])
990    };
991    let wanted = rest.join(".");
992    let key = section
993        .keys
994        .iter()
995        .find(|k| k.key == wanted)
996        .ok_or_else(|| {
997            format!(
998                "unknown key `{dotted}` (in [{}]: {})",
999                section.name,
1000                section
1001                    .keys
1002                    .iter()
1003                    .map(|k| k.key)
1004                    .collect::<Vec<_>>()
1005                    .join(", ")
1006            )
1007        })?;
1008    let mut tables = tables;
1009    let mut leaf = wanted.clone();
1010    if let Some((sub, last)) = wanted.rsplit_once('.') {
1011        tables.extend(sub.split('.').map(str::to_owned));
1012        leaf = last.to_owned();
1013    }
1014    Ok(Place { tables, leaf, key })
1015}
1016
1017/// Checks `value` against `kind` before any parser sees it, for a message
1018/// that names the key.
1019fn check(dotted: &str, kind: Kind, value: &Value) -> Result<(), String> {
1020    let strings = |v: &Value| v.as_array().is_some_and(|a| a.iter().all(Value::is_string));
1021    let ok = match kind {
1022        Kind::Bool => value.is_boolean(),
1023        Kind::Int(a, b) => value.as_i64().is_some_and(|n| (a..=b).contains(&n)),
1024        Kind::Num(a, b) => value.as_f64().is_some_and(|n| n >= a && n <= b),
1025        Kind::Str => value.as_str().is_some_and(|s| !s.is_empty()),
1026        Kind::Choice(c) => value.as_str().is_some_and(|s| c.contains(&s)),
1027        Kind::Duration => value.as_str().is_some_and(|s| {
1028            // The unit is the last character, which may be more than a byte.
1029            let (n, u) = s.split_at(s.char_indices().last().map_or(0, |(i, _)| i));
1030            n.parse::<u64>().is_ok_and(|n| n > 0) && ["m", "h", "d"].contains(&u)
1031        }),
1032        Kind::Argv => strings(value) && value.as_array().is_some_and(|a| !a.is_empty()),
1033        Kind::ArgvList => value.as_array().is_some_and(|a| {
1034            a.iter()
1035                .all(|x| strings(x) && x.as_array().is_some_and(|i| !i.is_empty()))
1036        }),
1037        Kind::StrList => strings(value),
1038    };
1039    if ok {
1040        return Ok(());
1041    }
1042    let want = match kind {
1043        Kind::Bool => "true or false".to_owned(),
1044        Kind::Int(a, b) => format!("a whole number from {a} to {b}"),
1045        Kind::Num(a, b) => format!("a number from {a} to {b}"),
1046        Kind::Str => "text".to_owned(),
1047        Kind::Choice(c) => format!("one of {}", c.join(", ")),
1048        Kind::Duration => "a duration like 30m, 12h or 7d".to_owned(),
1049        Kind::Argv => "a program and its arguments, e.g. [\"cargo\", \"fmt\"]".to_owned(),
1050        Kind::ArgvList => {
1051            "a list of programs with arguments, e.g. [[\"cargo\", \"check\"]]".to_owned()
1052        }
1053        Kind::StrList => "a list of strings".to_owned(),
1054    };
1055    Err(format!("`{dotted}` = {value}: expected {want}"))
1056}
1057
1058fn toml_value(v: &Value) -> Option<toml_edit::Value> {
1059    Some(match v {
1060        Value::Bool(b) => toml_edit::Value::from(*b),
1061        Value::Number(n) => match n.as_i64() {
1062            Some(i) => toml_edit::Value::from(i),
1063            None => toml_edit::Value::from(n.as_f64()?),
1064        },
1065        Value::String(s) => toml_edit::Value::from(s.as_str()),
1066        Value::Array(a) => {
1067            let mut arr = toml_edit::Array::new();
1068            for x in a {
1069                arr.push(toml_value(x)?);
1070            }
1071            toml_edit::Value::Array(arr)
1072        }
1073        Value::Null | Value::Object(_) => return None,
1074    })
1075}
1076
1077/// The table at `path` — a `[table]` or an inline `{ … }` — made when
1078/// missing and `create`; `None` when missing and not. The containers of
1079/// named tables (`[checks]`, `[mcp]`) are implicit: no header of their own.
1080fn table_mut<'a>(
1081    doc: &'a mut toml_edit::DocumentMut,
1082    path: &[String],
1083    create: bool,
1084) -> Result<Option<&'a mut dyn toml_edit::TableLike>, String> {
1085    let mut t: &mut dyn toml_edit::TableLike = doc.as_table_mut();
1086    let mut inline = false;
1087    for (i, p) in path.iter().enumerate() {
1088        if t.get(p).is_none() {
1089            if !create {
1090                return Ok(None);
1091            }
1092            let fresh = if inline {
1093                // Inside an inline table, what is made is inline too.
1094                toml_edit::Item::Value(toml_edit::Value::InlineTable(toml_edit::InlineTable::new()))
1095            } else {
1096                let mut table = toml_edit::Table::new();
1097                let container = i == 0 && CATALOGUE.iter().any(|c| c.named && c.name == p);
1098                table.set_implicit(container || i + 1 < path.len());
1099                toml_edit::Item::Table(table)
1100            };
1101            t.insert(p, fresh);
1102        }
1103        let Some(item) = t.get_mut(p) else {
1104            return Err(format!("`{}` could not be made", path[..=i].join(".")));
1105        };
1106        inline = inline || item.is_inline_table();
1107        t = item
1108            .as_table_like_mut()
1109            .ok_or_else(|| format!("`{}` is not a table", path[..=i].join(".")))?;
1110    }
1111    Ok(Some(t))
1112}
1113
1114/// Removes empty tables (or inline tables) left behind by an unset,
1115/// innermost first.
1116fn prune(doc: &mut toml_edit::DocumentMut, path: &[String]) {
1117    for depth in (1..=path.len()).rev() {
1118        let (parent, last) = (&path[..depth - 1], &path[depth - 1]);
1119        let Ok(Some(t)) = table_mut(doc, parent, false) else {
1120            return;
1121        };
1122        let empty = t
1123            .get(last)
1124            .and_then(toml_edit::Item::as_table_like)
1125            .is_some_and(toml_edit::TableLike::is_empty);
1126        if empty {
1127            t.remove(last);
1128        }
1129    }
1130}
1131
1132/// The new text with `changes` applied, or why not.
1133///
1134/// # Errors
1135///
1136/// An unknown key, a value of the wrong kind, or a result a parser refuses.
1137pub fn edit(text: &str, changes: &[ConfigChange]) -> Result<String, String> {
1138    let mut doc: toml_edit::DocumentMut = text
1139        .parse()
1140        .map_err(|e: toml_edit::TomlError| e.to_string())?;
1141    for c in changes {
1142        let at = place(&c.key)?;
1143        match (&c.value, &c.append) {
1144            (Some(v), None) => {
1145                check(&c.key, at.key.kind, v)?;
1146                let value = toml_value(v)
1147                    .ok_or_else(|| format!("`{}`: a value TOML cannot hold", c.key))?;
1148                let t = table_mut(&mut doc, &at.tables, true)?
1149                    .ok_or_else(|| format!("`{}` could not be made", c.key))?;
1150                match t.get_mut(&at.leaf).and_then(toml_edit::Item::as_value_mut) {
1151                    // Keep the key's own comment and spacing.
1152                    Some(old) => {
1153                        let decor = old.decor().clone();
1154                        *old = value;
1155                        *old.decor_mut() = decor;
1156                    }
1157                    None => {
1158                        t.insert(&at.leaf, toml_edit::value(value));
1159                    }
1160                }
1161            }
1162            (None, Some(item)) => {
1163                if !matches!(at.key.kind, Kind::ArgvList | Kind::StrList) {
1164                    return Err(format!("`{}` is not a list", c.key));
1165                }
1166                // The list as it is now, read back through the parser.
1167                let now: toml::Table = toml::from_str(&doc.to_string()).unwrap_or_default();
1168                let mut path: Vec<&str> = at.tables.iter().map(String::as_str).collect();
1169                path.push(&at.leaf);
1170                let mut list: Vec<Value> = lookup(&now, &path)
1171                    .map(json_of)
1172                    .and_then(|v| v.as_array().cloned())
1173                    .unwrap_or_default();
1174                if !list.contains(item) {
1175                    list.push(item.clone());
1176                }
1177                let all = Value::Array(list);
1178                check(&c.key, at.key.kind, &all)?;
1179                let value = toml_value(&all)
1180                    .ok_or_else(|| format!("`{}`: a value TOML cannot hold", c.key))?;
1181                table_mut(&mut doc, &at.tables, true)?
1182                    .ok_or_else(|| format!("`{}` could not be made", c.key))?
1183                    .insert(&at.leaf, toml_edit::value(value));
1184            }
1185            (None, None) => {
1186                // Missing is already unset; anything else in the way is an error.
1187                if let Some(t) = table_mut(&mut doc, &at.tables, false)? {
1188                    t.remove(&at.leaf);
1189                }
1190                prune(&mut doc, &at.tables);
1191            }
1192            (Some(_), Some(_)) => {
1193                return Err(format!(
1194                    "`{}`: set a value or append to it, not both",
1195                    c.key
1196                ));
1197            }
1198        }
1199    }
1200    let out = doc.to_string();
1201    match crate::substrate::model_config::parse(&out) {
1202        // A step on the way to Bedrock: its provider before its region,
1203        // which `config set model.region` adds next (until then every
1204        // command, and `config commit`, refuses the file and says so).
1205        Err(e) if e.starts_with(crate::substrate::model_config::NEEDS_REGION) => {}
1206        other => {
1207            other?;
1208        }
1209    }
1210    validate_but_model(&out)?;
1211    Ok(out)
1212}
1213
1214/// What a change to `[model]` leaves to do: Bedrock's region when it is
1215/// still missing (the step [`edit`] lets through), and a warning when a
1216/// change made the model a Bedrock one with an id Bedrock does not use.
1217fn model_notes(
1218    text: &str,
1219    changes: &[ConfigChange],
1220) -> (Vec<String>, Vec<speccoder_core::next::NextStep>) {
1221    use crate::substrate::model_config;
1222    let mut notes = Vec::new();
1223    let mut next = Vec::new();
1224    if model_config::parse(text).is_err_and(|e| e.starts_with(model_config::NEEDS_REGION)) {
1225        notes.push("provider bedrock needs model.region, set it next".to_owned());
1226        next.push(speccoder_core::next::NextStep {
1227            command: "speccoder config set model.region <region>".to_owned(),
1228            why: "provider bedrock needs the AWS region its calls go to".to_owned(),
1229        });
1230    }
1231    let touched = changes
1232        .iter()
1233        .any(|c| c.key == "model.provider" || c.key == "model.model");
1234    let model: toml::Table = toml::from_str(text).unwrap_or_default();
1235    let model = model.get("model").and_then(toml::Value::as_table);
1236    let field = |k: &str| model.and_then(|m| m.get(k)).and_then(toml::Value::as_str);
1237    if touched
1238        && field("provider") == Some("bedrock")
1239        && let Some(w) = field("model").and_then(model_config::bedrock_model_warning)
1240    {
1241        notes.push(format!("warning: {w}"));
1242    }
1243    (notes, next)
1244}
1245
1246/// `speccoder config`: the settings.
1247#[must_use]
1248pub fn show(root: &Path) -> CommandResult {
1249    let report = read(root);
1250    let message = match (&report.exists, &report.error, report.committed) {
1251        (false, _, _) => "no speccoder.toml yet: every key has its default".to_owned(),
1252        (true, Some(e), _) => format!("speccoder.toml has an error: {e}"),
1253        (true, None, false) => {
1254            "speccoder.toml has changes that are not committed (not in force yet)".to_owned()
1255        }
1256        (true, None, true) => "speccoder.toml".to_owned(),
1257    };
1258    let result = CommandResult::new("config", CommandStatus::Ok, &message);
1259    let result = if report.committed || !report.exists {
1260        result
1261    } else {
1262        result.with_next(vec![speccoder_core::next::NextStep {
1263            command: "speccoder config commit".to_owned(),
1264            why: "speccoder reads committed configuration only".to_owned(),
1265        }])
1266    };
1267    result.with_data(CommandData::Config(Box::new(report)))
1268}
1269
1270/// The keys only a coverage preview's confirmation writes.
1271pub const PREFIX_KEYS: [&str; 2] = ["commands.allow_prefix", "sandbox.network_allow_prefix"];
1272
1273/// `speccoder config set|unset`, and the page's save: applies `changes` to
1274/// the file, all or nothing. A prefix key is refused: it is saved only as
1275/// the confirmation of its coverage preview (`WidenPolicy`); removing an
1276/// entry (`unset`) narrows and is allowed.
1277#[must_use]
1278pub fn apply(root: &Path, changes: &[ConfigChange]) -> CommandResult {
1279    if let Some(c) = changes.iter().find(|c| {
1280        PREFIX_KEYS.contains(&c.key.as_str()) && (c.value.is_some() || c.append.is_some())
1281    }) {
1282        // Setting the list to fewer entries than it has narrows it.
1283        let narrows = c.value.as_ref().is_some_and(|v| {
1284            let now = read(root);
1285            let current = now
1286                .sections
1287                .iter()
1288                .flat_map(|s| {
1289                    s.keys
1290                        .iter()
1291                        .map(move |k| (format!("{}.{}", s.name, k.key), k))
1292                })
1293                .find(|(name, _)| *name == c.key)
1294                .and_then(|(_, k)| k.value.clone())
1295                .and_then(|v| v.as_array().cloned())
1296                .unwrap_or_default();
1297            v.as_array()
1298                .is_some_and(|new| new.iter().all(|e| current.contains(e)))
1299        });
1300        if !narrows {
1301            return CommandResult::new(
1302                "config",
1303                CommandStatus::UsageError,
1304                &format!(
1305                    "`{}` is saved only from its coverage preview: `speccoder config coverage {} <argv…>`, then confirm",
1306                    c.key, c.key
1307                ),
1308            )
1309            .with_code("CONFIG-INVALID");
1310        }
1311    }
1312    apply_confirmed(root, changes)
1313}
1314
1315/// [`apply`] without the prefix guard: for the confirmation of a coverage
1316/// preview and for Always Allow's exact entry, which their actions checked.
1317#[must_use]
1318pub(crate) fn apply_confirmed(root: &Path, changes: &[ConfigChange]) -> CommandResult {
1319    let dir = home(root);
1320    let path = dir.join(FILE);
1321    let text = std::fs::read_to_string(&path).unwrap_or_default();
1322    match edit(&text, changes) {
1323        Err(e) => CommandResult::new("config", CommandStatus::ConfigError, &e)
1324            .with_code("CONFIG-INVALID")
1325            .with_fix(vec![
1326                crate::substrate::model_config::config_fix(&e).unwrap_or_else(|| {
1327                    format!(
1328                        "speccoder config get {}",
1329                        changes.first().map_or("", |c| c.key.as_str())
1330                    )
1331                }),
1332            ]),
1333        Ok(out) if out == text => show(root),
1334        Ok(out) => match std::fs::write(&path, &out) {
1335            Ok(()) => {
1336                let keys: Vec<&str> = changes.iter().map(|c| c.key.as_str()).collect();
1337                let r = show(root);
1338                let (notes, mut next) = model_notes(&out, changes);
1339                next.push(speccoder_core::next::NextStep {
1340                    command: "speccoder config commit".to_owned(),
1341                    why: "speccoder reads committed configuration only".to_owned(),
1342                });
1343                let mut message =
1344                    format!("changed {} — commit it to put it in force", keys.join(", "));
1345                for n in notes {
1346                    message.push('\n');
1347                    message.push_str(&n);
1348                }
1349                CommandResult::new("config", CommandStatus::Ok, &message)
1350                    .with_next(next)
1351                    .with_data(
1352                        r.data()
1353                            .cloned()
1354                            .unwrap_or(CommandData::Config(Box::new(read(root)))),
1355                    )
1356            }
1357            Err(e) => CommandResult::new(
1358                "config",
1359                CommandStatus::Error,
1360                &format!("{}: {e}", path.display()),
1361            ),
1362        },
1363    }
1364}
1365
1366/// `speccoder config commit`: commits exactly `speccoder.toml`, nothing else
1367/// staged, once every parser accepts it.
1368#[must_use]
1369pub fn commit(root: &Path) -> CommandResult {
1370    let dir = home(root);
1371    let text = match std::fs::read_to_string(dir.join(FILE)) {
1372        Ok(t) => t,
1373        Err(_) => {
1374            return CommandResult::new(
1375                "config",
1376                CommandStatus::ConfigError,
1377                "there is no speccoder.toml to commit",
1378            )
1379            .with_code("CONFIG-MISSING");
1380        }
1381    };
1382    if let Err(e) = validate(&text) {
1383        return CommandResult::new("config", CommandStatus::ConfigError, &e)
1384            .with_code("CONFIG-INVALID");
1385    }
1386    let (committed, _) = state(&dir, true);
1387    if committed {
1388        return CommandResult::new(
1389            "config",
1390            CommandStatus::Ok,
1391            "speccoder.toml is committed; nothing to do",
1392        );
1393    }
1394    // Which sections changed, for the message.
1395    let g = Git::at(&dir);
1396    let before: toml::Table = g
1397        .run(&["show", &format!("HEAD:./{FILE}")])
1398        .ok()
1399        .and_then(|b| toml::from_str(&String::from_utf8_lossy(&b)).ok())
1400        .unwrap_or_default();
1401    let after: toml::Table = toml::from_str(&text).unwrap_or_default();
1402    let mut changed: Vec<&str> = CATALOGUE
1403        .iter()
1404        .map(|s| s.name)
1405        .filter(|s| before.get(*s) != after.get(*s))
1406        .collect();
1407    changed.dedup();
1408    let message = if changed.is_empty() {
1409        "speccoder: configuration".to_owned()
1410    } else {
1411        format!("speccoder: configuration ({})", changed.join(", "))
1412    };
1413    match g
1414        .run(&["add", "--", FILE])
1415        .and_then(|_| g.run(&["commit", "-q", "-m", &message, "--", FILE]))
1416    {
1417        Ok(_) => {
1418            let id = g
1419                .line(&["rev-parse", "--short", "HEAD"])
1420                .unwrap_or_default();
1421            CommandResult::new(
1422                "config",
1423                CommandStatus::Ok,
1424                &format!("committed speccoder.toml as {id} \"{message}\""),
1425            )
1426            .with_data(CommandData::Config(Box::new(read(root))))
1427        }
1428        Err(e) => CommandResult::new(
1429            "config",
1430            CommandStatus::GitPrecondition,
1431            &format!("commit failed: {}", e.0),
1432        ),
1433    }
1434}
1435
1436/// A value for `key` from the words of `config set KEY WORDS…`: JSON when it
1437/// parses as the key's kind, else words read by the kind (`cargo fmt` for an
1438/// argv, `40` for a number, `on`/`off` for a switch).
1439///
1440/// # Errors
1441///
1442/// An unknown key, or words that are no value of its kind.
1443pub fn value_of(key: &str, words: &[String]) -> Result<Value, String> {
1444    let at = place(key)?;
1445    let joined = words.join(" ");
1446    if let Ok(v) = serde_json::from_str::<Value>(&joined)
1447        && check(key, at.key.kind, &v).is_ok()
1448    {
1449        return Ok(v);
1450    }
1451    let v = match at.key.kind {
1452        Kind::Bool => match joined.as_str() {
1453            "true" | "on" | "yes" => json!(true),
1454            "false" | "off" | "no" => json!(false),
1455            _ => json!(joined),
1456        },
1457        Kind::Int(..) => joined
1458            .parse::<i64>()
1459            .map_or_else(|_| json!(joined), |n| json!(n)),
1460        Kind::Num(..) => joined
1461            .parse::<f64>()
1462            .map_or_else(|_| json!(joined), |n| json!(n)),
1463        Kind::Argv | Kind::StrList => json!(words),
1464        Kind::ArgvList => json!([words]),
1465        Kind::Str | Kind::Choice(_) | Kind::Duration => json!(joined),
1466    };
1467    check(key, at.key.kind, &v).map(|()| v)
1468}
1469
1470#[cfg(test)]
1471mod tests {
1472    use super::*;
1473
1474    /// A value of `kind` the parsers accept, for the drift test.
1475    fn sample(k: &Key) -> Value {
1476        if let Some(d) = k.default
1477            && let Ok(v) = serde_json::from_str::<Value>(d)
1478        {
1479            return v;
1480        }
1481        match k.kind {
1482            Kind::Bool => json!(true),
1483            Kind::Int(a, _) => json!(a.max(1)),
1484            Kind::Num(a, b) => json!(f64::midpoint(a, b).min(1.0)),
1485            Kind::Str => json!("x"),
1486            Kind::Choice(c) => json!(c[0]),
1487            Kind::Duration => json!("7d"),
1488            Kind::Argv => json!(["true"]),
1489            Kind::ArgvList => json!([["cargo", "check"]]),
1490            Kind::StrList => json!(["x"]),
1491        }
1492    }
1493
1494    /// The smallest configuration that holds `section` with `key` set.
1495    fn with(section: &Section, key: &Key) -> Vec<ConfigChange> {
1496        let prefix = if section.named {
1497            format!("{}.t", section.name)
1498        } else {
1499            section.name.to_owned()
1500        };
1501        let mut changes: Vec<ConfigChange> = section
1502            .keys
1503            .iter()
1504            .filter(|r| r.required && r.key != key.key)
1505            .map(|r| ConfigChange {
1506                key: format!("{prefix}.{}", r.key),
1507                value: Some(sample(r)),
1508                append: None,
1509            })
1510            .collect();
1511        changes.push(ConfigChange {
1512            key: format!("{prefix}.{}", key.key),
1513            value: Some(sample(key)),
1514            append: None,
1515        });
1516        // `argv`, `report` and `acceptance_argv` belong to a command tool,
1517        // which needs its program and report; the rest to cargo-mutants.
1518        if section.name == "mutation" {
1519            let set = |key: &str, value: Value| ConfigChange {
1520                key: key.into(),
1521                value: Some(value),
1522                append: None,
1523            };
1524            if ["argv", "report", "acceptance_argv"].contains(&key.key) {
1525                changes.retain(|c| c.key != "mutation.tool");
1526                changes.insert(0, set("mutation.tool", json!("command")));
1527                for (k, v) in [
1528                    ("mutation.argv", json!(["mutants", "{out}"])),
1529                    ("mutation.report", json!("report.json")),
1530                ] {
1531                    if !changes.iter().any(|c| c.key == k) {
1532                        changes.insert(1, set(k, v));
1533                    }
1534                }
1535            } else {
1536                changes.retain(|c| c.key != "mutation.tool" || key.key == "tool");
1537                if key.key != "tool" {
1538                    changes.insert(0, set("mutation.tool", json!("cargo-mutants")));
1539                }
1540            }
1541        }
1542        // An executor names its agent; `kind` and `argv` stand without one.
1543        if section.name == "executor" && !["agent", "kind", "argv"].contains(&key.key) {
1544            changes.insert(
1545                0,
1546                ConfigChange {
1547                    key: "executor.agent".into(),
1548                    value: Some(json!("copilot")),
1549                    append: None,
1550                },
1551            );
1552        }
1553        if section.name == "executor" && key.key == "argv" {
1554            changes.retain(|c| c.key != "executor.kind");
1555            changes.insert(
1556                0,
1557                ConfigChange {
1558                    key: "executor.kind".into(),
1559                    value: Some(json!("command")),
1560                    append: None,
1561                },
1562            );
1563        }
1564        if section.name == "model" && key.key == "base_url" {
1565            changes.push(ConfigChange {
1566                key: "model.provider".into(),
1567                value: Some(json!("openai-compatible")),
1568                append: None,
1569            });
1570        }
1571        if section.name == "model" && key.key == "region" {
1572            changes.push(ConfigChange {
1573                key: "model.provider".into(),
1574                value: Some(json!("bedrock")),
1575                append: None,
1576            });
1577        }
1578        changes
1579    }
1580
1581    #[test]
1582    fn every_catalogued_key_is_accepted_by_the_parsers() {
1583        for s in CATALOGUE {
1584            for k in s.keys {
1585                let changes = with(s, k);
1586                edit("", &changes).unwrap_or_else(|e| panic!("[{}] {}: {e}", s.name, k.key));
1587            }
1588        }
1589    }
1590
1591    #[test]
1592    fn every_key_in_the_reference_is_catalogued() {
1593        let doc = std::fs::read_to_string(
1594            Path::new(env!("CARGO_MANIFEST_DIR")).join("../../site/content/docs/configuration.md"),
1595        )
1596        .unwrap_or_default();
1597        assert!(!doc.is_empty(), "the configuration reference is missing");
1598        let mut section: Option<String> = None;
1599        let mut missing = Vec::new();
1600        for line in doc.lines() {
1601            if line.starts_with("## ") {
1602                // `## \`[model]\`` sets the section; a heading naming several
1603                // (or none) leaves rows to name their own.
1604                let names: Vec<&str> = line.split('`').skip(1).step_by(2).collect();
1605                section = (names.len() == 1).then(|| {
1606                    names[0]
1607                        .trim_matches(['[', ']'])
1608                        .split('.')
1609                        .next()
1610                        .unwrap_or("")
1611                        .to_owned()
1612                });
1613                if line.contains("Your settings") {
1614                    break;
1615                }
1616                continue;
1617            }
1618            let Some(cell) = line.strip_prefix("| ").and_then(|l| l.split(" |").next()) else {
1619                continue;
1620            };
1621            if cell.starts_with("Key") || cell.starts_with("---") {
1622                continue;
1623            }
1624            let mut prefix = section.clone().map(|s| vec![s]).unwrap_or_default();
1625            for token in cell.split('`').skip(1).step_by(2) {
1626                let key = match token.strip_prefix('[').and_then(|t| t.split_once("] ")) {
1627                    Some((table, key)) => {
1628                        prefix = table.split('.').map(str::to_owned).collect();
1629                        key.to_owned()
1630                    }
1631                    None => token.to_owned(),
1632                };
1633                let mut path = prefix.clone();
1634                if CATALOGUE
1635                    .iter()
1636                    .any(|c| c.named && Some(&c.name.to_owned()) == prefix.first())
1637                {
1638                    path.insert(1, "t".to_owned());
1639                }
1640                path.push(key);
1641                let dotted = path.join(".");
1642                if place(&dotted).is_err() {
1643                    missing.push(dotted);
1644                }
1645            }
1646        }
1647        assert!(
1648            missing.is_empty(),
1649            "documented but not catalogued: {missing:?}"
1650        );
1651    }
1652
1653    #[test]
1654    fn bedrock_is_set_provider_first_then_region() {
1655        let set = |key: &str, v: Value| ConfigChange {
1656            key: key.into(),
1657            value: Some(v),
1658            append: None,
1659        };
1660        let text = "[model]\nprovider = \"anthropic\"\nmodel = \"claude-sonnet-5-5\"\n";
1661        // A region before the provider is refused: it is Bedrock's.
1662        assert!(edit(text, &[set("model.region", json!("us-west-2"))]).is_err());
1663        let step = edit(text, &[set("model.provider", json!("bedrock"))]).unwrap();
1664        assert!(validate(&step).is_err(), "not in force without a region");
1665        let done = edit(&step, &[set("model.region", json!("us-west-2"))]).unwrap();
1666        let m = crate::substrate::model_config::parse(&done)
1667            .unwrap()
1668            .unwrap();
1669        assert_eq!(
1670            (m.provider.as_str(), m.region.as_deref()),
1671            ("bedrock", Some("us-west-2"))
1672        );
1673        // Anything else wrong is still refused on the way.
1674        assert!(edit(&step, &[set("model.max_output_tokens", json!(0))]).is_err());
1675        let p = place("model.provider").unwrap();
1676        assert!(matches!(p.key.kind, Kind::Choice(c) if c.contains(&"bedrock")));
1677    }
1678
1679    #[test]
1680    fn the_step_to_bedrock_is_let_through_only_when_the_region_is_all_that_is_missing() {
1681        let set = |key: &str, v: Value| ConfigChange {
1682            key: key.into(),
1683            value: Some(v),
1684            append: None,
1685        };
1686        // A key Bedrock does not use, left from the provider before.
1687        let compat = "[model]\nprovider = \"openai-compatible\"\nmodel = \"m.x\"\nbase_url = \"http://localhost:11434/v1\"\n";
1688        let e = edit(compat, &[set("model.provider", json!("bedrock"))]).unwrap_err();
1689        assert_eq!(
1690            e,
1691            "[model]: `base_url` does not apply to provider `bedrock` (its credentials and endpoint come from AWS) — speccoder config unset model.base_url"
1692        );
1693        // Its fix is the unset, from `config set` and from every command.
1694        assert_eq!(
1695            crate::substrate::model_config::config_fix(&e).as_deref(),
1696            Some("speccoder config unset model.base_url")
1697        );
1698        assert_eq!(
1699            crate::substrate::model_config::refused("run", &e).fix(),
1700            ["speccoder config unset model.base_url"]
1701        );
1702        // Any other `[model]` error on the way is still refused.
1703        let step = "[model]\nprovider = \"bedrock\"\nmodel = \"m.x\"\n";
1704        assert!(edit(step, &[set("model.model", json!(""))]).is_err());
1705        assert!(edit(step, &[set("model.structured", json!("tool"))]).is_err());
1706        assert!(edit(step, &[set("model.reasoning_summary", json!(false))]).is_ok());
1707    }
1708
1709    #[test]
1710    fn switching_to_bedrock_says_to_set_the_region_and_warns_of_a_model_id_bedrock_does_not_use() {
1711        let dir = tempfile::tempdir().unwrap();
1712        let ok = std::process::Command::new("git")
1713            .args(["init", "-q"])
1714            .current_dir(dir.path())
1715            .status()
1716            .unwrap();
1717        assert!(ok.success());
1718        std::fs::write(
1719            dir.path().join(FILE),
1720            "[model]\nprovider = \"anthropic\"\nmodel = \"claude-sonnet-5-5\"\n",
1721        )
1722        .unwrap();
1723        let r = apply(
1724            dir.path(),
1725            &[ConfigChange {
1726                key: "model.provider".into(),
1727                value: Some(json!("bedrock")),
1728                append: None,
1729            }],
1730        );
1731        assert_eq!(r.status(), CommandStatus::Ok, "{}", r.message());
1732        assert!(
1733            r.message().contains(
1734                "warning: `claude-sonnet-5-5` is not a Bedrock model id; Bedrock names models like global.anthropic.claude-sonnet-5-5"
1735            ),
1736            "{}",
1737            r.message()
1738        );
1739        assert_eq!(
1740            r.next().first().map(|n| n.command.as_str()),
1741            Some("speccoder config set model.region <region>")
1742        );
1743        // The listing marks the region required for this provider.
1744        let region = read(dir.path())
1745            .sections
1746            .into_iter()
1747            .find(|s| s.name == "model")
1748            .and_then(|s| s.keys.into_iter().find(|k| k.key == "region"))
1749            .unwrap();
1750        assert!(region.required);
1751        // With the region set: nothing left to say about it.
1752        let r = apply(
1753            dir.path(),
1754            &[ConfigChange {
1755                key: "model.region".into(),
1756                value: Some(json!("eu-central-1")),
1757                append: None,
1758            }],
1759        );
1760        assert_eq!(r.status(), CommandStatus::Ok, "{}", r.message());
1761        assert!(!r.message().contains("set it next"), "{}", r.message());
1762        assert_eq!(
1763            r.next().first().map(|n| n.command.as_str()),
1764            Some("speccoder config commit")
1765        );
1766        // An id Bedrock uses: no warning.
1767        assert_eq!(
1768            crate::substrate::model_config::bedrock_model_warning(
1769                "global.anthropic.claude-sonnet-5-5"
1770            ),
1771            None
1772        );
1773        assert_eq!(
1774            crate::substrate::model_config::bedrock_model_warning(
1775                "arn:aws:bedrock:eu-central-1:123456789012:inference-profile/x"
1776            ),
1777            None
1778        );
1779    }
1780
1781    #[test]
1782    fn an_edit_keeps_comments_and_refuses_what_a_parser_refuses() {
1783        let text = "# The project's checks.\n[checks.test]\nargv = [\"cargo\", \"test\"] # all of them\n\n# How much an attempt may do.\n[budget]\nmax_turns = 30 # plenty\n";
1784        let out = edit(
1785            text,
1786            &[ConfigChange {
1787                key: "budget.max_turns".into(),
1788                value: Some(json!(40)),
1789                append: None,
1790            }],
1791        )
1792        .unwrap();
1793        assert!(out.contains("# The project's checks."), "{out}");
1794        assert!(out.contains("# How much an attempt may do."), "{out}");
1795        assert!(out.contains("max_turns = 40 # plenty"), "{out}");
1796        assert!(
1797            out.contains("argv = [\"cargo\", \"test\"] # all of them"),
1798            "{out}"
1799        );
1800        // Out of range, unknown, the wrong kind: nothing is written.
1801        for (key, value) in [
1802            ("budget.max_turns", json!(0)),
1803            ("budget.max_turn", json!(3)),
1804            ("sandbox.mode", json!("maybe")),
1805            ("ui.theme", json!("dark")),
1806        ] {
1807            assert!(
1808                edit(
1809                    text,
1810                    &[ConfigChange {
1811                        key: key.into(),
1812                        value: Some(value),
1813                        append: None
1814                    }]
1815                )
1816                .is_err(),
1817                "{key}"
1818            );
1819        }
1820        // Unset removes the key and an emptied table.
1821        let out = edit(
1822            text,
1823            &[ConfigChange {
1824                key: "budget.max_turns".into(),
1825                value: None,
1826                append: None,
1827            }],
1828        )
1829        .unwrap();
1830        assert!(!out.contains("[budget]"), "{out}");
1831        // Append adds once.
1832        let allow = ConfigChange {
1833            key: "commands.allow".into(),
1834            value: None,
1835            append: Some(json!(["cargo", "fetch"])),
1836        };
1837        let once = edit(text, std::slice::from_ref(&allow)).unwrap();
1838        let twice = edit(&once, &[allow]).unwrap();
1839        assert_eq!(once, twice);
1840        assert!(once.contains("allow = [[\"cargo\", \"fetch\"]]"), "{once}");
1841        // A named table is made under an implicit container.
1842        let out = edit(
1843            "",
1844            &[ConfigChange {
1845                key: "checks.lint.argv".into(),
1846                value: Some(json!(["cargo", "clippy"])),
1847                append: None,
1848            }],
1849        )
1850        .unwrap();
1851        assert!(
1852            out.contains("[checks.lint]") && !out.contains("[checks]\n"),
1853            "{out}"
1854        );
1855    }
1856
1857    /// A unit of more than one byte is refused, never a panic (it aborted
1858    /// the web host).
1859    #[test]
1860    fn a_multibyte_unit_is_refused_not_a_panic() {
1861        let w = |s: &str| vec![s.to_owned()];
1862        for bad in ["7д", "1€", "д", "€"] {
1863            assert!(value_of("approvals.wait", &w(bad)).is_err(), "{bad}");
1864        }
1865        assert!(crate::substrate::run_config::parse("[approvals]\nwait = \"7д\"\n").is_err());
1866    }
1867
1868    /// Inline tables are tables: a key in one is set and unset like any.
1869    #[test]
1870    fn inline_tables_are_edited_not_skipped() {
1871        let text = "[attest]\nsigstore = { identity = \"a@b\", issuer = \"https://x\" }\n\n[checks]\nlint = { argv = [\"cargo\", \"clippy\"] }\n";
1872        let unset = |k: &str| ConfigChange {
1873            key: k.into(),
1874            value: None,
1875            append: None,
1876        };
1877        let set = |k: &str, v: Value| ConfigChange {
1878            key: k.into(),
1879            value: Some(v),
1880            append: None,
1881        };
1882        let out =
1883            edit(text, &[unset("attest.sigstore.identity")]).unwrap_or_else(|e| panic!("{e}"));
1884        assert!(!out.contains("a@b") && out.contains("issuer"), "{out}");
1885        let out =
1886            edit(text, &[set("checks.lint.tier", json!("full"))]).unwrap_or_else(|e| panic!("{e}"));
1887        assert!(out.contains("tier = \"full\""), "{out}");
1888        let out = edit(
1889            text,
1890            &[
1891                unset("attest.sigstore.identity"),
1892                unset("attest.sigstore.issuer"),
1893            ],
1894        )
1895        .unwrap_or_else(|e| panic!("{e}"));
1896        assert!(!out.contains("sigstore"), "{out}");
1897    }
1898
1899    #[test]
1900    fn words_become_values_of_the_key_s_kind() {
1901        let w = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1902        assert_eq!(value_of("budget.max_turns", &w("40")).unwrap(), json!(40));
1903        assert_eq!(value_of("monitor.enabled", &w("on")).unwrap(), json!(true));
1904        assert_eq!(
1905            value_of("format.argv", &w("cargo fmt")).unwrap(),
1906            json!(["cargo", "fmt"])
1907        );
1908        assert_eq!(
1909            value_of("commands.allow", &w("cargo check")).unwrap(),
1910            json!([["cargo", "check"]])
1911        );
1912        assert_eq!(value_of("approvals.wait", &w("2d")).unwrap(), json!("2d"));
1913        assert!(value_of("budget.max_turns", &w("lots")).is_err());
1914    }
1915}