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