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