1use 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#[derive(Debug, Clone, PartialEq)]
38pub struct StrengthConfig {
39 pub enabled: bool,
41 pub min: Option<f64>,
43 pub near_miss: u32,
45 pub permitted: u32,
48 pub timeout: std::time::Duration,
50}
51
52impl 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Signer {
123 Git,
125 Sigstore,
127 None,
129}
130
131impl Signer {
132 #[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#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct AttestConfig {
146 pub enabled: bool,
148 pub signer: Signer,
150 pub sign_later: bool,
152 pub sigstore_identity: Option<String>,
155 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
194pub const DEFAULT_BUDGET: u32 = 12;
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum Cap {
201 Tokens(u64),
203 Usd(u64),
205}
206
207impl Cap {
208 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 #[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 #[must_use]
243 pub fn amount(self) -> u64 {
244 match self {
245 Self::Tokens(n) | Self::Usd(n) => n,
246 }
247 }
248
249 #[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 #[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 #[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#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
316pub struct RunConfig {
317 pub max_turns: u32,
319 pub max_tool_calls: u32,
321 pub max_attempts: u32,
323 pub best_of: u32,
325 pub cap: Option<Cap>,
327 pub allow: Vec<Vec<String>>,
329 pub allow_prefix: Vec<Vec<String>>,
332 pub approval_budget: u32,
335 pub contained: bool,
338 pub commit_per_task: bool,
340 pub acceptance: bool,
342 pub held_out: bool,
345 pub strength: StrengthConfig,
347 pub max_converge_rounds: u32,
349 pub monitor: bool,
351 pub review_required: bool,
354 pub oracle_samples: u8,
358 pub frozen_additions: bool,
361 pub format: Option<Vec<String>>,
364 pub approval_wait: std::time::Duration,
367 pub attest: AttestConfig,
369 pub instructions: Vec<String>,
372 pub reproduce_tries: u32,
374 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
410fn wait(text: &str) -> Result<std::time::Duration, String> {
412 let text = text.trim();
413 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
429pub 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}