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