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