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