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