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