Skip to main content

speccoder_runtime/substrate/
run_config.rs

1//! `[budget]` and `[commands]` in `speccoder.toml` (policy).
2
3use serde::Deserialize;
4
5#[derive(Debug, Default, Deserialize)]
6#[serde(deny_unknown_fields)]
7struct RawBudget {
8    max_turns: Option<u32>,
9    max_tool_calls: Option<u32>,
10    max_attempts: Option<u32>,
11    best_of: Option<u32>,
12}
13
14#[derive(Debug, Default, Deserialize)]
15#[serde(deny_unknown_fields)]
16struct RawBug {
17    reproduce_tries: Option<u32>,
18    test: Option<Vec<String>>,
19}
20
21#[derive(Debug, Default, Deserialize)]
22#[serde(deny_unknown_fields)]
23struct RawAcceptance {
24    generate: Option<bool>,
25    held_out: Option<bool>,
26    strength: Option<bool>,
27    min_strength: Option<f64>,
28    near_miss: Option<u32>,
29    permitted: Option<u32>,
30    strength_timeout_secs: Option<u64>,
31}
32
33/// How the acceptance set's strength is measured: `[acceptance]`
34/// `strength`, `min_strength`, `near_miss`, `permitted`,
35/// `strength_timeout_secs`.
36#[derive(Debug, Clone, PartialEq)]
37pub struct StrengthConfig {
38    /// Measure after every `verified` verdict (default on).
39    pub enabled: bool,
40    /// The gate; `None` (default): strength never changes a verdict.
41    pub min: Option<f64>,
42    /// Wrong variants asked for (0–3, default 2).
43    pub near_miss: u32,
44    /// Spec-permitted alternatives asked for (0–2, default 1; 0: no
45    /// over-strict check).
46    pub permitted: u32,
47    /// The whole stage's budget.
48    pub timeout: std::time::Duration,
49}
50
51// `min` is validated to 0.0–1.0, so it is never NaN.
52impl Eq for StrengthConfig {}
53
54impl Default for StrengthConfig {
55    fn default() -> Self {
56        Self {
57            enabled: true,
58            min: None,
59            near_miss: 2,
60            permitted: 1,
61            timeout: std::time::Duration::from_secs(900),
62        }
63    }
64}
65
66#[derive(Debug, Default, Deserialize)]
67#[serde(deny_unknown_fields)]
68struct RawFormat {
69    argv: Vec<String>,
70}
71
72#[derive(Debug, Default, Deserialize)]
73#[serde(deny_unknown_fields)]
74struct RawTests {
75    frozen: Option<String>,
76}
77
78#[derive(Debug, Default, Deserialize)]
79#[serde(deny_unknown_fields)]
80struct RawMonitor {
81    enabled: Option<bool>,
82}
83
84#[derive(Debug, Default, Deserialize)]
85#[serde(deny_unknown_fields)]
86struct RawContext {
87    instructions: Option<Vec<String>>,
88}
89
90#[derive(Debug, Default, Deserialize)]
91#[serde(deny_unknown_fields)]
92struct RawConverge {
93    max_rounds: Option<u32>,
94}
95
96#[derive(Debug, Default, Deserialize)]
97#[serde(deny_unknown_fields)]
98struct RawReview {
99    required: Option<bool>,
100    oracle_samples: Option<i64>,
101}
102
103#[derive(Debug, Default, Deserialize)]
104#[serde(deny_unknown_fields)]
105struct RawAttest {
106    enabled: Option<bool>,
107    signer: Option<String>,
108    sign_at: Option<String>,
109    sigstore: Option<RawSigstore>,
110}
111
112#[derive(Debug, Default, Deserialize)]
113#[serde(deny_unknown_fields)]
114struct RawSigstore {
115    identity: Option<String>,
116    issuer: Option<String>,
117}
118
119/// Who signs a task's attestation (`[attest] signer`).
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum Signer {
122    /// The person's git signing key (SSH or OpenPGP), as git would use it.
123    Git,
124    /// Sigstore keyless, through `cosign`.
125    Sigstore,
126    /// Nobody: statements are stored unsigned.
127    None,
128}
129
130impl Signer {
131    /// The configured word.
132    #[must_use]
133    pub const fn word(self) -> &'static str {
134        match self {
135            Self::Git => "git",
136            Self::Sigstore => "sigstore",
137            Self::None => "none",
138        }
139    }
140}
141
142/// `[attest]`: signed evidence per ticked task.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct AttestConfig {
145    /// Write attestations on ticks.
146    pub enabled: bool,
147    /// Who signs.
148    pub signer: Signer,
149    /// Sign at the tick, or later with `speccoder attest sign` (touch keys).
150    pub sign_later: bool,
151    /// The Sigstore certificate identity the project says signs: shown to
152    /// reviewers (`pr`) to confirm, never trusted by `attest verify`.
153    pub sigstore_identity: Option<String>,
154    /// That identity's OIDC issuer, likewise.
155    pub sigstore_issuer: Option<String>,
156}
157
158impl Default for AttestConfig {
159    fn default() -> Self {
160        Self {
161            enabled: true,
162            signer: Signer::Git,
163            sign_later: false,
164            sigstore_identity: None,
165            sigstore_issuer: None,
166        }
167    }
168}
169
170#[derive(Debug, Default, Deserialize)]
171#[serde(deny_unknown_fields)]
172struct RawApprovals {
173    wait: Option<String>,
174    budget: Option<i64>,
175    contained: Option<String>,
176}
177
178#[derive(Debug, Default, Deserialize)]
179#[serde(deny_unknown_fields)]
180struct RawGit {
181    commit: Option<String>,
182}
183
184#[derive(Debug, Default, Deserialize)]
185#[serde(deny_unknown_fields)]
186struct RawCommands {
187    #[serde(default)]
188    allow: Vec<Vec<String>>,
189    #[serde(default)]
190    allow_prefix: Vec<Vec<String>>,
191}
192
193/// `[approvals] budget` unless configured: prompts a run shows a person.
194pub const DEFAULT_BUDGET: u32 = 12;
195
196/// Limits and command allowances for an attempt.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct RunConfig {
199    /// Model turns per attempt.
200    pub max_turns: u32,
201    /// Tool calls per attempt.
202    pub max_tool_calls: u32,
203    /// Attempts per task run; failed verdicts are retried up to this.
204    pub max_attempts: u32,
205    /// Independent samples per task, the best by evidence kept.
206    pub best_of: u32,
207    /// Exact argvs `run_command` runs without asking (`[commands] allow`).
208    pub allow: Vec<Vec<String>>,
209    /// Argv prefixes (two elements or more) it runs without asking
210    /// (`[commands] allow_prefix`), saved only from a coverage preview.
211    pub allow_prefix: Vec<Vec<String>>,
212    /// Prompts a run shows a person before further calls wait together in
213    /// Needs You (`[approvals] budget`, 0–100, default 12).
214    pub approval_budget: u32,
215    /// Commands the enforced sandbox confines run without asking
216    /// (`[approvals] contained = "allow"`, the default; `"ask"` asks).
217    pub contained: bool,
218    /// Commit each passed task on the speccoder branch (`[git] commit = "per-task"`).
219    pub commit_per_task: bool,
220    /// Derive acceptance tests from the spec before attempts (`[acceptance] generate`).
221    pub acceptance: bool,
222    /// Also author held-out acceptance tests the implementer never sees
223    /// (`[acceptance] held_out`, default on; nothing without `generate`).
224    pub held_out: bool,
225    /// Acceptance strength.
226    pub strength: StrengthConfig,
227    /// Convergence phases a feature may get (`[converge] max_rounds`).
228    pub max_converge_rounds: u32,
229    /// Ask the gaming monitor about every passing task (`[monitor] enabled`).
230    pub monitor: bool,
231    /// A run waits until every requirement is approved on its current
232    /// wording (`[review] required`).
233    pub review_required: bool,
234    /// Independent author calls *Write Tests* makes at review
235    /// (`[review] oracle_samples`, 1 or 2, default 2): two are compared for
236    /// ambiguity, one turns the comparison off.
237    pub oracle_samples: u8,
238    /// Test files that existed before an attempt may gain lines without
239    /// approval (`[tests] frozen = "additions"`; default `"ask"`).
240    pub frozen_additions: bool,
241    /// The formatter speccoder runs after each attempt, before verifying
242    /// (`[format] argv`): formatting is not the model's job to remember.
243    pub format: Option<Vec<String>>,
244    /// How long a call waits for a person before it is denied as expired
245    /// (`[approvals] wait`, default 7 days).
246    pub approval_wait: std::time::Duration,
247    /// `[attest]`.
248    pub attest: AttestConfig,
249    /// `[context] instructions`: the file names read per directory as the
250    /// repository's instructions for agents; empty: none.
251    pub instructions: Vec<String>,
252    /// `[bug] reproduce_tries`: the reproduction author's tries (1–5).
253    pub reproduce_tries: u32,
254    /// `[bug] test`: how one named test runs (`{file}`, `{name}` filled),
255    /// when the toolchain's own rule does not fit.
256    pub bug_test: Option<Vec<String>>,
257}
258
259impl Default for RunConfig {
260    fn default() -> Self {
261        Self {
262            max_turns: 30,
263            max_tool_calls: 100,
264            max_attempts: 2,
265            best_of: 1,
266            allow: Vec::new(),
267            allow_prefix: Vec::new(),
268            approval_budget: DEFAULT_BUDGET,
269            contained: true,
270            commit_per_task: true,
271            acceptance: true,
272            held_out: true,
273            strength: StrengthConfig::default(),
274            max_converge_rounds: 3,
275            monitor: false,
276            review_required: false,
277            oracle_samples: 2,
278            frozen_additions: false,
279            format: None,
280            approval_wait: std::time::Duration::from_secs(7 * 24 * 3600),
281            attest: AttestConfig::default(),
282            instructions: vec!["AGENTS.md".to_owned()],
283            reproduce_tries: speccoder_core::bug::DEFAULT_TRIES,
284            bug_test: None,
285        }
286    }
287}
288
289/// `30m`, `12h` or `7d`; never zero.
290fn wait(text: &str) -> Result<std::time::Duration, String> {
291    let text = text.trim();
292    // The unit is the last character, which may be more than a byte (`7д`).
293    let (n, unit) = text.split_at(text.char_indices().last().map_or(0, |(i, _)| i));
294    let secs = match unit {
295        "m" => 60,
296        "h" => 3600,
297        "d" => 86_400,
298        _ => 0,
299    };
300    match n.parse::<u64>() {
301        Ok(n) if n > 0 && secs > 0 => Ok(std::time::Duration::from_secs(n.saturating_mul(secs))),
302        _ => Err(format!(
303            "[approvals]: wait = \"{text}\" (a positive number of m, h or d, e.g. \"7d\")"
304        )),
305    }
306}
307
308/// Reads `[budget]` and `[commands]`; absent sections give defaults.
309pub fn parse(text: &str) -> Result<RunConfig, String> {
310    let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
311    let budget: RawBudget = match table.get("budget") {
312        Some(v) => v.clone().try_into().map_err(|e| format!("[budget]: {e}"))?,
313        None => RawBudget::default(),
314    };
315    let commands: RawCommands = match table.get("commands") {
316        Some(v) => v
317            .clone()
318            .try_into()
319            .map_err(|e| format!("[commands]: {e}"))?,
320        None => RawCommands::default(),
321    };
322    let git: RawGit = match table.get("git") {
323        Some(v) => v.clone().try_into().map_err(|e| format!("[git]: {e}"))?,
324        None => RawGit::default(),
325    };
326    let commit_per_task = match git.commit.as_deref() {
327        None | Some("per-task") => true,
328        Some("none") => false,
329        Some(other) => return Err(format!("[git]: commit = \"{other}\" (per-task or none)")),
330    };
331    let acceptance: RawAcceptance = match table.get("acceptance") {
332        Some(v) => v
333            .clone()
334            .try_into()
335            .map_err(|e| format!("[acceptance]: {e}"))?,
336        None => RawAcceptance::default(),
337    };
338    let converge: RawConverge = match table.get("converge") {
339        Some(v) => v
340            .clone()
341            .try_into()
342            .map_err(|e| format!("[converge]: {e}"))?,
343        None => RawConverge::default(),
344    };
345    let review: RawReview = match table.get("review") {
346        Some(v) => v.clone().try_into().map_err(|e| format!("[review]: {e}"))?,
347        None => RawReview::default(),
348    };
349    let monitor: RawMonitor = match table.get("monitor") {
350        Some(v) => v
351            .clone()
352            .try_into()
353            .map_err(|e| format!("[monitor]: {e}"))?,
354        None => RawMonitor::default(),
355    };
356    let tests: RawTests = match table.get("tests") {
357        Some(v) => v.clone().try_into().map_err(|e| format!("[tests]: {e}"))?,
358        None => RawTests::default(),
359    };
360    let frozen_additions = match tests.frozen.as_deref() {
361        None | Some("ask") => false,
362        Some("additions") => true,
363        Some(other) => {
364            return Err(format!("[tests]: frozen = \"{other}\" (ask or additions)"));
365        }
366    };
367    let format: Option<RawFormat> = match table.get("format") {
368        Some(v) => Some(v.clone().try_into().map_err(|e| format!("[format]: {e}"))?),
369        None => None,
370    };
371    if format
372        .as_ref()
373        .is_some_and(|f| f.argv.first().is_none_or(String::is_empty))
374    {
375        return Err("[format]: `argv` must name a program".to_owned());
376    }
377    let approvals: RawApprovals = match table.get("approvals") {
378        Some(v) => v
379            .clone()
380            .try_into()
381            .map_err(|e| format!("[approvals]: {e}"))?,
382        None => RawApprovals::default(),
383    };
384    let raw_attest: RawAttest = match table.get("attest") {
385        Some(v) => v.clone().try_into().map_err(|e| format!("[attest]: {e}"))?,
386        None => RawAttest::default(),
387    };
388    let attest = AttestConfig {
389        enabled: raw_attest.enabled.unwrap_or(true),
390        signer: match raw_attest.signer.as_deref() {
391            None | Some("git") => Signer::Git,
392            Some("sigstore") => Signer::Sigstore,
393            Some("none") => Signer::None,
394            Some(other) => {
395                return Err(format!(
396                    "[attest]: signer = \"{other}\" (git, sigstore or none)"
397                ));
398            }
399        },
400        sign_later: match raw_attest.sign_at.as_deref() {
401            None | Some("tick") => false,
402            Some("later") => true,
403            Some(other) => {
404                return Err(format!("[attest]: sign_at = \"{other}\" (tick or later)"));
405            }
406        },
407        sigstore_identity: raw_attest
408            .sigstore
409            .as_ref()
410            .and_then(|s| s.identity.clone()),
411        sigstore_issuer: raw_attest.sigstore.and_then(|s| s.issuer),
412    };
413    let context: RawContext = match table.get("context") {
414        Some(v) => v
415            .clone()
416            .try_into()
417            .map_err(|e| format!("[context]: {e}"))?,
418        None => RawContext::default(),
419    };
420    if let Some(bad) = context.instructions.iter().flatten().find(|n| {
421        n.is_empty() || n.contains('/') || n.contains('\\') || matches!(n.as_str(), "." | "..")
422    }) {
423        return Err(format!(
424            "[context]: instructions = \"{bad}\": a file name such as AGENTS.md, without a directory"
425        ));
426    }
427    let d = RunConfig::default();
428    let strength = {
429        let ds = StrengthConfig::default();
430        if let Some(m) = acceptance.min_strength
431            && !(0.0..=1.0).contains(&m)
432        {
433            return Err(format!(
434                "[acceptance]: min_strength = {m} (a share from 0.0 to 1.0)"
435            ));
436        }
437        let near_miss = acceptance.near_miss.unwrap_or(ds.near_miss);
438        if near_miss > 3 {
439            return Err(format!("[acceptance]: near_miss = {near_miss} (0 to 3)"));
440        }
441        let permitted = acceptance.permitted.unwrap_or(ds.permitted);
442        if permitted > 2 {
443            return Err(format!("[acceptance]: permitted = {permitted} (0 to 2)"));
444        }
445        StrengthConfig {
446            enabled: acceptance.strength.unwrap_or(ds.enabled),
447            min: acceptance.min_strength,
448            near_miss,
449            permitted,
450            timeout: acceptance
451                .strength_timeout_secs
452                .map_or(ds.timeout, |s| std::time::Duration::from_secs(s.max(1))),
453        }
454    };
455    let bug: RawBug = match table.get("bug") {
456        Some(v) => v.clone().try_into().map_err(|e| format!("[bug]: {e}"))?,
457        None => RawBug::default(),
458    };
459    let reproduce_tries = bug.reproduce_tries.unwrap_or(d.reproduce_tries);
460    if !(1..=speccoder_core::bug::MAX_TRIES).contains(&reproduce_tries) {
461        return Err(format!(
462            "[bug]: reproduce_tries = {reproduce_tries} (1 to {})",
463            speccoder_core::bug::MAX_TRIES
464        ));
465    }
466    if bug.test.as_ref().is_some_and(Vec::is_empty) {
467        return Err("[bug]: test = [] (a program and its arguments)".to_owned());
468    }
469    let approval_wait = match approvals.wait.as_deref() {
470        Some(w) => wait(w)?,
471        None => d.approval_wait,
472    };
473    let approval_budget = match approvals.budget {
474        None => d.approval_budget,
475        Some(n) => u32::try_from(n)
476            .ok()
477            .filter(|n| *n <= 100)
478            .ok_or_else(|| format!("[approvals]: budget = {n} (a whole number from 0 to 100)"))?,
479    };
480    let contained = match approvals.contained.as_deref() {
481        None | Some("allow") => true,
482        Some("ask") => false,
483        Some(other) => {
484            return Err(format!(
485                "[approvals]: contained = \"{other}\" (allow or ask)"
486            ));
487        }
488    };
489    let oracle_samples = match review.oracle_samples {
490        None => d.oracle_samples,
491        Some(n @ 1..=2) => u8::try_from(n).unwrap_or(d.oracle_samples),
492        Some(n) => return Err(format!("[review]: oracle_samples = {n} (1 or 2)")),
493    };
494    if let Some(why) = commands
495        .allow_prefix
496        .iter()
497        .find_map(|p| crate::tools::policy::prefix_refusal(p))
498    {
499        return Err(format!("[commands] allow_prefix: {why}"));
500    }
501    Ok(RunConfig {
502        max_turns: budget.max_turns.unwrap_or(d.max_turns).max(1),
503        max_tool_calls: budget.max_tool_calls.unwrap_or(d.max_tool_calls),
504        max_attempts: budget.max_attempts.unwrap_or(d.max_attempts).max(1),
505        best_of: budget.best_of.unwrap_or(d.best_of).clamp(1, 10),
506        allow: commands
507            .allow
508            .into_iter()
509            .filter(|p| !p.is_empty())
510            .collect(),
511        allow_prefix: commands.allow_prefix,
512        approval_budget,
513        contained,
514        commit_per_task,
515        acceptance: acceptance.generate.unwrap_or(true),
516        held_out: acceptance.held_out.unwrap_or(true),
517        strength,
518        max_converge_rounds: converge.max_rounds.unwrap_or(d.max_converge_rounds),
519        monitor: monitor.enabled.unwrap_or(false),
520        review_required: review.required.unwrap_or(false),
521        oracle_samples,
522        frozen_additions,
523        format: format.map(|f| f.argv),
524        approval_wait,
525        attest,
526        instructions: context.instructions.unwrap_or(d.instructions),
527        reproduce_tries,
528        bug_test: bug.test,
529    })
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    #[test]
537    fn bug_tries_default_to_three_and_stay_within_five() {
538        assert_eq!(parse("").unwrap().reproduce_tries, 3);
539        assert_eq!(
540            parse("[bug]\nreproduce_tries = 5\n")
541                .unwrap()
542                .reproduce_tries,
543            5
544        );
545        assert!(parse("[bug]\nreproduce_tries = 6\n").is_err());
546        assert!(parse("[bug]\nreproduce_tries = 0\n").is_err());
547        assert!(parse("[bug]\ntries = 2\n").is_err());
548        let c = parse("[bug]\ntest = [\"sh\", \"t.sh\", \"{file}\", \"{name}\"]\n").unwrap();
549        assert_eq!(c.bug_test.unwrap_or_default().len(), 4);
550    }
551
552    #[test]
553    fn agents_md_is_read_unless_configured_otherwise() {
554        assert_eq!(parse("").unwrap().instructions, ["AGENTS.md"]);
555        let c = parse("[context]\ninstructions = [\"AGENTS.md\", \"CLAUDE.md\"]\n").unwrap();
556        assert_eq!(c.instructions, ["AGENTS.md", "CLAUDE.md"]);
557        assert!(
558            parse("[context]\ninstructions = []\n")
559                .unwrap()
560                .instructions
561                .is_empty()
562        );
563        assert!(parse("[context]\ninstructions = [\"docs/AGENTS.md\"]\n").is_err());
564        assert!(parse("[context]\nfiles = []\n").is_err());
565    }
566
567    #[test]
568    fn attestations_are_on_and_signed_by_git_unless_configured() {
569        let d = parse("").unwrap_or_default().attest;
570        assert!(d.enabled && d.signer == Signer::Git && !d.sign_later);
571        let c = parse("[attest]\nsigner = \"sigstore\"\nsign_at = \"later\"\n[attest.sigstore]\nidentity = \"ada@example.com\"\nissuer = \"https://accounts.example\"\n")
572            .unwrap_or_default()
573            .attest;
574        assert_eq!((c.signer, c.sign_later), (Signer::Sigstore, true));
575        assert_eq!(c.sigstore_identity.as_deref(), Some("ada@example.com"));
576        assert!(parse("[attest]\nsigner = \"x509\"\n").is_err());
577        assert!(parse("[attest]\nsign_at = \"never\"\n").is_err());
578        assert!(parse("[attest]\nkey = \"x\"\n").is_err());
579    }
580
581    #[test]
582    fn an_approval_waits_seven_days_unless_configured() {
583        assert_eq!(
584            parse("").unwrap_or_default().approval_wait.as_secs(),
585            7 * 86_400
586        );
587        let w = |t: &str| {
588            parse(&format!("[approvals]\nwait = \"{t}\"\n")).map(|c| c.approval_wait.as_secs())
589        };
590        assert_eq!(w("30m"), Ok(1800));
591        assert_eq!(w("12h"), Ok(43_200));
592        for bad in ["0d", "7", "7w", "-1d", ""] {
593            assert!(w(bad).is_err(), "{bad}");
594        }
595    }
596
597    #[test]
598    fn strength_is_measured_by_default_and_bounded() {
599        let c = parse("").unwrap().strength;
600        assert!(c.enabled && c.min.is_none());
601        assert_eq!((c.near_miss, c.permitted), (2, 1));
602        let c = parse("[acceptance]\nmin_strength = 0.8\nnear_miss = 3\npermitted = 0\nstrength_timeout_secs = 60\n")
603            .unwrap()
604            .strength;
605        assert_eq!(c.min, Some(0.8));
606        assert_eq!((c.near_miss, c.permitted, c.timeout.as_secs()), (3, 0, 60));
607        assert!(
608            !parse("[acceptance]\nstrength = false\n")
609                .unwrap()
610                .strength
611                .enabled
612        );
613        for bad in ["min_strength = 1.5", "near_miss = 4", "permitted = 3"] {
614            assert!(parse(&format!("[acceptance]\n{bad}\n")).is_err(), "{bad}");
615        }
616    }
617
618    #[test]
619    fn oracle_samples_are_one_or_two() {
620        assert_eq!(parse("").unwrap().oracle_samples, 2);
621        assert_eq!(
622            parse("[review]\noracle_samples = 1\n")
623                .unwrap()
624                .oracle_samples,
625            1
626        );
627        for bad in ["0", "3", "-1"] {
628            assert!(parse(&format!("[review]\noracle_samples = {bad}\n")).is_err());
629        }
630    }
631
632    #[test]
633    fn held_out_is_on_by_default_and_can_be_switched_off() {
634        assert!(parse("").unwrap().held_out);
635        let c = parse("[acceptance]\nheld_out = false\n").unwrap();
636        assert!(!c.held_out && c.acceptance);
637        assert!(parse("[acceptance]\nheldout = true\n").is_err());
638    }
639
640    #[test]
641    fn defaults_and_overrides() {
642        assert_eq!(parse("").unwrap(), RunConfig::default());
643        let c =
644            parse("[budget]\nmax_turns = 5\n[commands]\nallow = [[\"cargo\", \"fmt\"]]\n").unwrap();
645        assert_eq!(c.max_turns, 5);
646        assert_eq!(c.max_tool_calls, 100);
647        assert_eq!(c.max_attempts, 2);
648        assert_eq!(
649            parse("[budget]\nmax_attempts = 0\n").unwrap().max_attempts,
650            1
651        );
652        assert_eq!(c.allow, [vec!["cargo".to_owned(), "fmt".to_owned()]]);
653        assert!(parse("[budget]\nmax_cost = 1\n").is_err());
654        assert!(!parse("[git]\ncommit = \"none\"\n").unwrap().commit_per_task);
655        assert!(parse("[git]\ncommit = \"always\"\n").is_err());
656        assert!(
657            parse("[tests]\nfrozen = \"additions\"\n")
658                .unwrap()
659                .frozen_additions
660        );
661        assert!(
662            !parse("[tests]\nfrozen = \"ask\"\n")
663                .unwrap()
664                .frozen_additions
665        );
666        assert!(parse("[tests]\nfrozen = \"anything\"\n").is_err());
667    }
668
669    #[test]
670    fn the_asking_budget_and_prefixes_are_checked() {
671        assert_eq!(parse("").unwrap().approval_budget, 12);
672        assert_eq!(
673            parse("[approvals]\nbudget = 0\n").unwrap().approval_budget,
674            0
675        );
676        for bad in ["101", "-1"] {
677            let e = parse(&format!("[approvals]\nbudget = {bad}\n")).unwrap_err();
678            assert!(e.contains("budget"), "{e}");
679        }
680        let c = parse("[commands]\nallow_prefix = [[\"npm\", \"run\"]]\n").unwrap();
681        assert_eq!(c.allow_prefix, [vec!["npm".to_owned(), "run".to_owned()]]);
682        let e = parse("[commands]\nallow_prefix = [[\"python3\"]]\n").unwrap_err();
683        assert!(e.contains("a bare program allows any argument"), "{e}");
684    }
685
686    #[test]
687    fn contained_calls_are_allowed_unless_asked_for() {
688        assert!(parse("").unwrap().contained);
689        assert!(
690            parse("[approvals]\ncontained = \"allow\"\n")
691                .unwrap()
692                .contained
693        );
694        assert!(
695            !parse("[approvals]\ncontained = \"ask\"\n")
696                .unwrap()
697                .contained
698        );
699        let e = parse("[approvals]\ncontained = \"yes\"\n").unwrap_err();
700        assert_eq!(e, "[approvals]: contained = \"yes\" (allow or ask)");
701    }
702}