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