1use std::collections::BTreeSet;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use clap::{Args, Parser, Subcommand};
8
9use crate::agent::{self, Agent};
10use crate::config::{self, Config};
11use crate::error::Result;
12use crate::model::{Issue, IssueRun, ItemKind, Ledger, Plan, Status};
13use crate::proc::{self, ExecOpts};
14use crate::repo::Repo;
15use crate::review;
16use crate::review_only;
17use crate::style;
18use crate::triage;
19use crate::{bail, log, logdim, logging, logwarn, spar_err};
20
21pub const VERSION: &str = env!("CARGO_PKG_VERSION");
22
23#[derive(Parser, Debug)]
24#[command(
25 name = "spar",
26 version = VERSION,
27 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
28 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
29 pull request converges. Neither agent reviews its own most recent edit.\n\n\
30 Arguments are issue numbers for `run` and `triage`, and pull request numbers \
31 for `resume`. Omit them and spar takes everything open, up to --limit.",
32 max_term_width = 96
33)]
34pub struct Cli {
35 #[arg(short, long, global = true)]
37 pub quiet: bool,
38
39 #[command(subcommand)]
40 pub command: Command,
41}
42
43#[derive(Subcommand, Debug)]
44pub enum Command {
45 Run {
47 issues: Vec<i64>,
49 #[command(flatten)]
50 common: Common,
51 #[command(flatten)]
52 loop_flags: LoopFlags,
53 #[command(flatten)]
54 triage_flags: TriageFlags,
55 #[arg(long, default_value = "plan.json")]
57 plan_out: PathBuf,
58 #[arg(long)]
60 no_worktrees: bool,
61 },
62
63 Triage {
65 issues: Vec<i64>,
67 #[command(flatten)]
68 common: Common,
69 #[arg(long, default_value = "plan.json")]
70 plan_out: PathBuf,
71 },
72
73 Resume {
75 prs: Vec<i64>,
77 #[command(flatten)]
78 common: Common,
79 #[command(flatten)]
80 loop_flags: LoopFlags,
81 #[arg(long = "next", value_name = "AGENT")]
83 next_actor: Option<String>,
84 },
85
86 Review {
91 items: Vec<i64>,
94 #[command(flatten)]
95 common: Common,
96 #[arg(long)]
98 dry_run: bool,
99 #[arg(long)]
102 max_rounds: Option<u32>,
103 },
104
105 Post {
110 #[arg(required = true)]
112 prs: Vec<i64>,
113 #[arg(long, default_value = ".")]
114 repo: PathBuf,
115 #[arg(long)]
116 config: Option<PathBuf>,
117 #[arg(long, value_name = "PATH")]
119 file: Option<PathBuf>,
120 #[arg(long)]
122 dry_run: bool,
123 },
124
125 Init {
130 #[arg(long, default_value = "spar.toml")]
131 out: PathBuf,
132 #[arg(long)]
134 force: bool,
135 #[arg(long, conflicts_with = "force")]
138 update: bool,
139 },
140
141 Clean {
143 #[arg(long, default_value = ".")]
144 repo: PathBuf,
145 #[arg(long)]
146 config: Option<PathBuf>,
147 #[arg(long)]
149 all: bool,
150 #[arg(long)]
152 pr_state: bool,
153 },
154
155 Doctor {
157 #[arg(long)]
158 config: Option<PathBuf>,
159 },
160
161 #[command(hide = true)]
165 ScrubFilter,
166}
167
168#[derive(Args, Debug, Clone)]
169pub struct Common {
170 #[arg(long, default_value = ".")]
172 pub repo: PathBuf,
173 #[arg(long)]
175 pub config: Option<PathBuf>,
176 #[arg(long)]
178 pub base: Option<String>,
179 #[arg(long)]
181 pub first: Option<String>,
182 #[arg(long, default_value_t = 20)]
184 pub limit: usize,
185 #[arg(long, value_name = "N")]
188 pub min_number: Option<i64>,
189}
190
191#[derive(Args, Debug, Clone)]
192pub struct LoopFlags {
193 #[arg(long)]
196 pub max_rounds: Option<u32>,
197 #[arg(long)]
199 pub auto_merge: bool,
200 #[arg(long)]
202 pub keep_worktrees: bool,
203 #[arg(long, value_name = "N")]
206 pub absorb: Option<u32>,
207}
208
209#[derive(Args, Debug, Clone)]
212pub struct TriageFlags {
213 #[arg(long, conflicts_with = "no_close_skipped")]
215 pub close_skipped: bool,
216 #[arg(long)]
218 pub no_close_skipped: bool,
219}
220
221pub fn main() -> i32 {
226 let cli = Cli::parse();
227 logging::init_color();
228 logging::set_quiet(cli.quiet);
229
230 match dispatch(cli) {
231 Ok(code) => code,
232 Err(e) => {
233 logging::error(e.to_string());
234 2
235 }
236 }
237}
238
239fn dispatch(cli: Cli) -> Result<i32> {
240 match cli.command {
241 Command::ScrubFilter => cmd_scrub_filter(),
242 Command::Doctor { config } => cmd_doctor(config.as_deref()),
243 Command::Review {
244 items,
245 common,
246 dry_run,
247 max_rounds,
248 } => {
249 let overrides = Overrides {
250 max_rounds,
251 ..Overrides::default()
252 };
253 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
254 let numbers = if items.is_empty() {
255 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
256 if found.is_empty() {
257 log!("no open PRs");
258 return Ok(0);
259 }
260 log!("no PRs given, reviewing {} open", found.len());
261 found
262 } else {
263 items
264 };
265 let sorted = classify(&repo, &numbers)?;
266 let mut targets = sorted.prs;
267 for number in sorted.issues {
268 match repo.open_pr_for_issue(number) {
269 Some(pr) => {
270 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
271 targets.push(pr.number);
272 }
273 None => logwarn!("#{number} is an issue with no open pull request to review"),
274 }
275 }
276 let mut results = Vec::new();
277 for number in targets {
278 results.push(review_only::review_pr(
279 &agents, &cfg, &repo, number, dry_run,
280 ));
281 }
282 if results.is_empty() {
283 return Ok(0);
284 }
285 Ok(report(&results, &cfg))
286 }
287
288 Command::Post {
289 prs,
290 repo: repo_path,
291 config,
292 file,
293 dry_run,
294 } => cmd_post(
295 &prs,
296 &repo_path,
297 config.as_deref(),
298 file.as_deref(),
299 dry_run,
300 ),
301
302 Command::Init { out, force, update } => {
303 if update {
304 cmd_init_update(&out)
305 } else {
306 cmd_init(&out, force)
307 }
308 }
309 Command::Clean {
310 repo,
311 config,
312 all,
313 pr_state,
314 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
315 Command::Triage {
316 issues,
317 common,
318 plan_out,
319 } => {
320 let (cfg, repo, agents) = prepare(&common, None)?;
321 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
322 if numbers.is_empty() {
323 return Ok(0);
324 }
325 let sorted = classify(&repo, &numbers)?;
326 for number in &sorted.prs {
327 log!("#{number} is a pull request, nothing to triage");
328 }
329 if sorted.issues.is_empty() {
330 log!("no issues to triage");
331 return Ok(0);
332 }
333 let issues = repo.fetch_issues(&sorted.issues)?;
334 make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
338 Ok(0)
339 }
340 Command::Run {
341 issues,
342 common,
343 loop_flags,
344 triage_flags,
345 plan_out,
346 no_worktrees,
347 } => {
348 let mut overrides = Overrides::from(&loop_flags);
349 overrides.worktrees = if no_worktrees { Some(false) } else { None };
350 overrides.close_skipped =
351 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
352 (true, _) => Some(true),
353 (_, true) => Some(false),
354 _ => None,
355 };
356 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
357 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
358 if numbers.is_empty() {
359 return Ok(0);
360 }
361 let sorted = classify(&repo, &numbers)?;
362 let mut results = Vec::new();
363 let mut ledger = Ledger::new();
364 let mut handled: BTreeSet<i64> = BTreeSet::new();
365 let mut wave = sorted.issues.clone();
366
367 for round in 0..=cfg.loop_cfg.absorb_new_issues {
372 wave.retain(|n| !handled.contains(n));
373 if wave.is_empty() {
374 break;
375 }
376 if round > 0 {
377 log!(
378 "absorbing {} newly filed issue(s): {}",
379 wave.len(),
380 wave.iter()
381 .map(|n| format!("#{n}"))
382 .collect::<Vec<_>>()
383 .join(", ")
384 );
385 }
386 handled.extend(wave.iter().copied());
387
388 let fetched = match repo.fetch_issues(&wave) {
389 Ok(fetched) => fetched,
390 Err(e) => {
391 logdim!("could not read the next wave: {e}");
392 break;
393 }
394 };
395 let plan_path = if round == 0 {
396 plan_out.clone()
397 } else {
398 plan_out.with_extension(format!("wave{round}.json"))
399 };
400 let plan = make_plan(&agents, &cfg, &repo, &fetched, &plan_path)?;
401 act_on_plan(&cfg, &repo, &plan);
402
403 let before = results.len();
404 for item in &plan.order {
405 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
406 continue;
407 };
408 results.push(review::run_issue(
409 &agents,
410 &cfg,
411 &repo,
412 item,
413 issue,
414 &mut ledger,
415 ));
416 }
417
418 wave = results[before..]
420 .iter()
421 .flat_map(|r| r.filed.iter())
422 .filter_map(|url| review::filed_issue_number(url))
423 .collect::<BTreeSet<_>>()
424 .into_iter()
425 .collect();
426 }
427 if !wave.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
428 log!(
429 "{} issue(s) filed in the last wave were left for a later run: {}",
430 wave.len(),
431 wave.iter()
432 .map(|n| format!("#{n}"))
433 .collect::<Vec<_>>()
434 .join(", ")
435 );
436 }
437
438 for number in sorted.prs {
439 results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
440 }
441
442 if results.is_empty() {
443 log!("nothing scheduled");
444 return Ok(0);
445 }
446 Ok(report(&results, &cfg))
447 }
448 Command::Resume {
449 prs,
450 common,
451 loop_flags,
452 next_actor,
453 } => {
454 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
455 if let Some(name) = &next_actor {
456 if !cfg.has_agent(name) {
457 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
458 }
459 }
460 let numbers = if prs.is_empty() {
461 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
462 if found.is_empty() {
463 log!("no open PRs");
464 return Ok(0);
465 }
466 log!(
467 "no PRs given, taking {} open: {}",
468 found.len(),
469 found
470 .iter()
471 .map(|n| format!("#{n}"))
472 .collect::<Vec<_>>()
473 .join(", ")
474 );
475 found
476 } else {
477 prs
478 };
479 let sorted = classify(&repo, &numbers)?;
480 let mut results = Vec::new();
481 for number in sorted.prs {
482 results.push(review::resume_pr(
483 &agents,
484 &cfg,
485 &repo,
486 number,
487 next_actor.as_deref(),
488 ));
489 }
490 for number in sorted.issues {
493 match repo.open_pr_for_issue(number) {
494 Some(pr) => {
495 log!("#{number} is an issue; continuing its open PR {}", pr.url);
496 results.push(review::resume_pr(
497 &agents,
498 &cfg,
499 &repo,
500 pr.number,
501 next_actor.as_deref(),
502 ));
503 }
504 None => logwarn!(
505 "#{number} is an issue with no open pull request. Use `spar run {number}` \
506 to implement it."
507 ),
508 }
509 }
510 if results.is_empty() {
511 return Ok(0);
512 }
513 Ok(report(&results, &cfg))
514 }
515 }
516}
517
518#[derive(Debug, Default, Clone)]
523struct Overrides {
524 max_rounds: Option<u32>,
525 auto_merge: Option<bool>,
526 keep_worktrees: Option<bool>,
527 worktrees: Option<bool>,
528 close_skipped: Option<bool>,
529 absorb: Option<u32>,
530}
531
532impl From<&LoopFlags> for Overrides {
533 fn from(flags: &LoopFlags) -> Self {
534 Self {
535 max_rounds: flags.max_rounds,
536 auto_merge: flags.auto_merge.then_some(true),
537 keep_worktrees: flags.keep_worktrees.then_some(true),
538 worktrees: None,
539 close_skipped: None,
540 absorb: flags.absorb,
541 }
542 }
543}
544
545fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
546 let mut cfg = config::load(common.config.as_deref())?;
547
548 if let Some(first) = &common.first {
549 if !cfg.has_agent(first) {
550 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
551 }
552 cfg.first_implementor = first.clone();
553 }
554 if let Some(base) = &common.base {
555 cfg.loop_cfg.base_branch = base.clone();
556 }
557 if let Some(min) = common.min_number {
558 cfg.loop_cfg.min_number = min;
559 }
560 if let Some(over) = overrides {
561 if let Some(v) = over.max_rounds {
562 if v == 0 {
563 bail!("--max-rounds must be at least 1");
564 }
565 cfg.loop_cfg.max_rounds = v;
566 }
567 if let Some(v) = over.auto_merge {
568 cfg.loop_cfg.auto_merge = v;
569 }
570 if let Some(v) = over.keep_worktrees {
571 cfg.loop_cfg.keep_worktrees = v;
572 }
573 if let Some(v) = over.worktrees {
574 cfg.loop_cfg.worktrees = v;
575 }
576 if let Some(v) = over.close_skipped {
577 cfg.loop_cfg.close_skipped = v;
578 }
579 if let Some(v) = over.absorb {
580 cfg.loop_cfg.absorb_new_issues = v;
581 }
582 }
583
584 let repo = Repo::open(&common.repo, &cfg)?;
585 if common.base.is_none() {
586 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
587 }
588
589 let agents = agent::build(&cfg)?;
590 if let Some(warning) = agent::correlation_warning(&agents) {
591 logging::warn(warning);
592 }
593
594 for stale in repo.prune_worktrees(false) {
596 let what = if stale.starts_with("branch ") {
597 stale
598 } else {
599 format!("worktree {stale}")
600 };
601 logdim!("cleaned up finished {what}");
602 }
603
604 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
605 log!(
606 "agents: {}",
607 agents
608 .iter()
609 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
610 .collect::<Vec<_>>()
611 .join(", ")
612 );
613 Ok((cfg, repo, agents))
614}
615
616fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
617 if !given.is_empty() {
618 if min_number > 0 {
620 let below: Vec<String> = given
621 .iter()
622 .filter(|n| **n < min_number)
623 .map(|n| format!("#{n}"))
624 .collect();
625 if !below.is_empty() {
626 logdim!(
627 "{} below the #{min_number} floor, taking them because you named them",
628 below.join(", ")
629 );
630 }
631 }
632 return Ok(given);
633 }
634 let found = repo.list_open_issues(limit, min_number)?;
635 if found.is_empty() {
636 log!("no open issues");
637 return Ok(found);
638 }
639 log!(
640 "no issues given, taking {} open: {}",
641 found.len(),
642 found
643 .iter()
644 .map(|n| format!("#{n}"))
645 .collect::<Vec<_>>()
646 .join(", ")
647 );
648 Ok(found)
649}
650
651#[derive(Debug, Default)]
657struct Sorted {
658 issues: Vec<i64>,
659 prs: Vec<i64>,
660}
661
662fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
663 let mut sorted = Sorted::default();
664 for number in numbers {
665 match repo.item_kind(*number)? {
666 ItemKind::Issue => sorted.issues.push(*number),
667 ItemKind::Pr => sorted.prs.push(*number),
668 }
669 }
670 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
671 log!(
672 "{} issue(s) and {} pull request(s) given",
673 sorted.issues.len(),
674 sorted.prs.len()
675 );
676 }
677 Ok(sorted)
678}
679
680fn make_plan(
681 agents: &[Agent],
682 cfg: &Config,
683 repo: &Repo,
684 issues: &[Issue],
685 plan_out: &Path,
686) -> Result<Plan> {
687 let plan = triage::triage(agents, cfg, repo, issues)?;
688
689 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
690 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
691 log!("plan written to {}", plan_out.display());
692
693 for item in &plan.order {
694 log!(
695 " do #{} [{}/{}] {}",
696 item.issue,
697 item.complexity,
698 item.risk,
699 item.title
700 );
701 }
702 for item in &plan.skipped {
703 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
704 }
705 for item in &plan.contested {
706 log!(" ?? #{} contested, parked for you to decide", item.issue);
707 }
708 Ok(plan)
709}
710
711fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
714 for item in &plan.skipped {
715 let body = review::skip_comment(item, &repo.style);
716 let outcome = if cfg.loop_cfg.close_skipped {
717 repo.close_issue(item.issue, &body)
718 } else {
719 repo.comment_issue(item.issue, &body)
720 };
721 match outcome {
722 Ok(()) if cfg.loop_cfg.close_skipped => log!(" closed #{}", item.issue),
723 Ok(()) => {}
724 Err(e) => logdim!("could not update #{}: {e}", item.issue),
725 }
726 }
727}
728
729fn cmd_scrub_filter() -> Result<i32> {
734 let mut input = String::new();
735 std::io::stdin()
736 .read_to_string(&mut input)
737 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
738 let out = style::scrub(&input, &crate::repo::style_from_env());
739 let mut stdout = std::io::stdout();
740 stdout
741 .write_all(out.as_bytes())
742 .and_then(|_| stdout.write_all(b"\n"))
743 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
744 Ok(0)
745}
746
747fn cmd_clean(
748 repo_path: &Path,
749 config_path: Option<&Path>,
750 all: bool,
751 pr_state: bool,
752) -> Result<i32> {
753 let cfg = config::load(config_path)?;
754 let repo = Repo::open(repo_path, &cfg)?;
755 let mut removed = repo.prune_worktrees(all);
756 removed.extend(repo.prune_state());
757 if pr_state {
758 removed.extend(repo.prune_pr_state(None));
759 }
760 if removed.is_empty() {
761 println!("nothing to clean");
762 } else {
763 for item in removed {
764 println!("removed {item}");
765 }
766 }
767 Ok(0)
768}
769
770fn cmd_post(
772 prs: &[i64],
773 repo_path: &Path,
774 config_path: Option<&Path>,
775 file: Option<&Path>,
776 dry_run: bool,
777) -> Result<i32> {
778 let cfg = config::load(config_path)?;
779 let repo = Repo::open(repo_path, &cfg)?;
780
781 if file.is_some() && prs.len() > 1 {
782 bail!("--file posts one review, so give it one pull request number");
783 }
784
785 let mut failed = false;
786 for number in prs {
787 let text = match file {
788 Some(path) => std::fs::read_to_string(path)
789 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
790 None => match repo.read_pending_comment(*number) {
791 Some(text) => text,
792 None => {
793 logging::error(format!(
794 "no saved review for PR #{number}. `spar review {number} --dry-run` \
795 produces one, or pass --file."
796 ));
797 failed = true;
798 continue;
799 }
800 },
801 };
802 if text.trim().is_empty() {
803 logging::error(format!("the saved review for PR #{number} is empty"));
804 failed = true;
805 continue;
806 }
807 if dry_run {
808 println!("\n{}\n", text.trim());
809 log!("would post the above to PR #{number}");
810 continue;
811 }
812 match repo.comment_pr(*number, &text) {
815 Ok(()) => log!("posted to PR #{number}"),
816 Err(e) => {
817 logging::error(format!("could not post to PR #{number}: {e}"));
818 failed = true;
819 }
820 }
821 }
822 Ok(if failed { 1 } else { 0 })
823}
824
825fn cmd_init_update(out: &Path) -> Result<i32> {
831 let text = std::fs::read_to_string(out)
832 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
833 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
836
837 let unset = config::unmentioned_options(&text);
838 if unset.is_empty() {
839 println!("{} already mentions every setting.", out.display());
840 return Ok(0);
841 }
842
843 let mut block = String::new();
844 if !text.ends_with('\n') {
845 block.push('\n');
846 }
847 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
848 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
849 let mut section = "";
850 for option in &unset {
851 if option.section != section {
852 section = option.section;
853 block.push_str(&format!("# [{section}]\n"));
854 }
855 block.push_str(&format!("# {} = {}\n", option.key, option.default));
856 }
857
858 use std::io::Write;
859 std::fs::OpenOptions::new()
860 .append(true)
861 .open(out)
862 .and_then(|mut f| f.write_all(block.as_bytes()))
863 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
864
865 println!(
866 "added {} setting(s) to {} as comments",
867 unset.len(),
868 out.display()
869 );
870 Ok(0)
871}
872
873fn cmd_init(out: &Path, force: bool) -> Result<i32> {
874 if out.exists() && !force {
875 logging::error(format!(
876 "{} already exists. `--update` appends any settings it does not mention, \
877 `--force` overwrites it.",
878 out.display()
879 ));
880 return Ok(1);
881 }
882
883 let presets = config::available_presets();
884 if presets.is_empty() {
885 bail!("no presets available, which should be impossible in a released build");
886 }
887
888 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
889 for name in &presets {
890 let raw = config::load_preset(name)?;
891 let mut spec: config::AgentSpec = match raw
895 .as_table()
896 .cloned()
897 .ok_or_else(|| spar_err!("not a table"))
898 .and_then(|t| {
899 toml::Value::Table(t)
900 .try_into()
901 .map_err(|e| spar_err!("{e}"))
902 }) {
903 Ok(spec) => spec,
904 Err(e) => {
905 println!(" BROKEN {name:10} {}", e.first_line());
906 continue;
907 }
908 };
909 spec.name = name.clone();
910 match Agent::new(spec.clone()).resolve_bin() {
911 Ok(path) => {
912 println!(" found {name:10} {}", path.display());
913 found.push((name.clone(), path.to_path_buf(), spec));
914 }
915 Err(_) => println!(" missing {name}"),
916 }
917 }
918
919 if found.len() < 2 {
920 logging::error(format!(
921 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
922 presets as a reference.",
923 found.len(),
924 out.display()
925 ));
926 return Ok(1);
927 }
928
929 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
931 if found.len() > 2 {
932 log!(
933 "{} agents available, picking {} and {}. Edit {} to change.",
934 found.len(),
935 chosen[0].0,
936 chosen[1].0,
937 out.display()
938 );
939 }
940
941 let mut text = String::from(
942 "# Generated by `spar init`. Each agent inherits a command template from a\n\
943 # built in preset; anything set here overrides it.\n\
944 #\n\
945 # Commented lines are the other options, each with a working value.\n\
946 # Uncomment one to change it.\n\n",
947 );
948 for (name, _, spec) in &chosen {
949 text.push_str(&agent_block(name, spec));
950 }
951 text.push_str(&settings_block(&chosen[0].0));
952
953 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
954 println!("\nwrote {}", out.display());
955 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
956 Ok(0)
957}
958
959fn report_fallback(agent: &Agent) {
965 let Some(backup) = agent.fallback() else {
966 return;
967 };
968 match backup.resolve_bin() {
969 Ok(bin) => println!(
970 " fallback {} ({})",
971 bin.display(),
972 backup.spec.describe()
973 ),
974 Err(_) => println!(
975 " fallback {} not found, so it will not stand in. Set {} to its path.",
976 backup.program(),
977 backup.env_key()
978 ),
979 }
980}
981
982type Setting = (bool, &'static str, &'static str);
993
994const LOOP_OPTIONS: &[Setting] = &[
995 (
996 false,
997 "max_rounds",
998 "review rounds ONE invocation may spend. Resuming grants a fresh budget, so this is not a lifetime cap on a PR.",
999 ),
1000 (
1001 false,
1002 "auto_merge",
1003 "off on purpose: two models agreeing is not the same as being right",
1004 ),
1005 (false, "first_implementor", ""),
1006 (false, "worktrees", "false works in the main checkout"),
1007 (
1008 false,
1009 "close_skipped",
1010 "close an issue both reviewers declined",
1011 ),
1012 (
1013 false,
1014 "followups",
1015 "issues | local | none. local writes .spar/followups.md, not the tracker",
1016 ),
1017 (
1018 true,
1019 "file_non_blocking",
1020 "a suggestion is not a tracker item",
1021 ),
1022 (
1023 true,
1024 "max_followups",
1025 "backstop on what one run can spawn",
1026 ),
1027 (
1028 true,
1029 "keep_worktrees",
1030 "true leaves them behind to inspect",
1031 ),
1032 (
1033 true,
1034 "min_number",
1035 "ignore anything numbered below this when picking for itself. 0 is no floor.",
1036 ),
1037 (
1038 true,
1039 "parallel_triage",
1040 "false asks the agents one at a time",
1041 ),
1042 (
1043 true,
1044 "absorb_new_issues",
1045 "waves of newly filed follow-ups to fold back into this run. Costs more.",
1046 ),
1047 (true, "file_nits", "true files nits as issues too"),
1048 (
1049 true,
1050 "base_branch",
1051 "only a fallback; origin/HEAD wins when it resolves",
1052 ),
1053 (
1054 true,
1055 "branch_prefix",
1056 "e.g. \"spar/\" to namespace the branches spar creates",
1057 ),
1058 (true, "state_store", "local | pr | both"),
1059];
1060
1061const STYLE_OPTIONS: &[Setting] = &[
1062 (false, "ban_em_dash", ""),
1063 (false, "ban_ai_attribution", ""),
1064 (
1065 false,
1066 "terse",
1067 "hold model prose to a length budget. false removes the valves entirely",
1068 ),
1069 (
1070 true,
1071 "pr_comments",
1072 "outcome | rounds | none. How much of its own working spar narrates into a PR thread. none never comments at all.",
1073 ),
1074 (
1075 true,
1076 "max_title_chars",
1077 "a finding, issue, or PR title. Never ellipsised",
1078 ),
1079 (
1080 true,
1081 "max_summary_chars",
1082 "a one line verdict or refutation",
1083 ),
1084 (
1085 true,
1086 "max_detail_chars",
1087 "a blocking finding, in the PR thread",
1088 ),
1089 (true, "max_body_chars", "a PR body"),
1090 (
1091 true,
1092 "max_issue_body_chars",
1093 "a filed issue's body. Far larger on purpose: an issue is picked up cold. Fenced code blocks in one are never truncated and never count against this.",
1094 ),
1095];
1096
1097fn settings_block(first_implementor: &str) -> String {
1102 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1103 .into_iter()
1104 .map(|option| (option.key, option.default))
1105 .collect();
1106 let value = |key: &str| match key {
1109 "first_implementor" => format!("\"{first_implementor}\""),
1110 other => defaults.get(other).cloned().unwrap_or_default(),
1111 };
1112
1113 let mut out = String::from("[loop]\n");
1114 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1115 out.push_str(concat!(
1116 "\n[loop.effort_schedule]\n",
1117 "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1118 "# these are examples rather than defaults. Left out, each agent uses\n",
1119 "# the effort its own block asked for.\n",
1120 "# round_1 = \"high\" # the deep first review\n",
1121 "# rest = \"low\" # later rounds only see a small delta\n\n",
1122 ));
1123 out.push_str("[style]\n");
1124 out.push_str(&option_lines(STYLE_OPTIONS, &value));
1125 out
1126}
1127
1128fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1132 const WIDTH: usize = 78;
1133
1134 let assignments: Vec<String> = options
1135 .iter()
1136 .map(|(commented, key, _)| {
1137 let lead = if *commented { "# " } else { "" };
1138 format!("{lead}{key} = {}", value(key))
1139 })
1140 .collect();
1141 let column = assignments
1142 .iter()
1143 .map(|a| a.chars().count())
1144 .max()
1145 .unwrap_or(0)
1146 + 2;
1147
1148 let mut out = String::new();
1149 for (assignment, (_, _, note)) in assignments.iter().zip(options) {
1150 if note.is_empty() {
1151 out.push_str(assignment);
1152 out.push('\n');
1153 continue;
1154 }
1155 let mut first = true;
1156 let mut line = String::new();
1157 for word in note.split_whitespace() {
1158 let would_be = column + 2 + line.chars().count() + 1 + word.chars().count();
1159 if !line.is_empty() && would_be > WIDTH {
1160 out.push_str(¬ed(assignment, &line, column, &mut first));
1161 line.clear();
1162 }
1163 if !line.is_empty() {
1164 line.push(' ');
1165 }
1166 line.push_str(word);
1167 }
1168 if !line.is_empty() {
1169 out.push_str(¬ed(assignment, &line, column, &mut first));
1170 }
1171 }
1172 out
1173}
1174
1175fn noted(assignment: &str, note: &str, column: usize, first: &mut bool) -> String {
1178 let lead = if *first {
1179 let pad = column.saturating_sub(assignment.chars().count());
1180 format!("{assignment}{}", " ".repeat(pad))
1181 } else {
1182 " ".repeat(column)
1183 };
1184 *first = false;
1185 format!("{lead}# {note}\n")
1186}
1187
1188type Probe = Box<dyn Fn() -> Result<String>>;
1191
1192fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1199 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1200 out.push_str("# Omit model or effort to use the CLI's own default.\n");
1201
1202 fn suggested(choices: &[String]) -> &str {
1206 choices.first().map(String::as_str).unwrap_or("...")
1207 }
1208 let assignments = [
1209 format!("# model = \"{}\"", suggested(&spec.models)),
1210 format!("# effort = \"{}\"", suggested(&spec.efforts)),
1211 ];
1212 let column = assignments
1214 .iter()
1215 .map(|a| a.chars().count())
1216 .max()
1217 .unwrap_or(0)
1218 + 3;
1219 for (assignment, choices) in assignments.iter().zip([&spec.models, &spec.efforts]) {
1220 out.push_str(assignment);
1221 if choices.len() > 1 {
1222 let pad = column.saturating_sub(assignment.chars().count());
1223 out.push_str(&" ".repeat(pad));
1224 out.push_str(&format!("# {}", choices.join(" | ")));
1225 }
1226 out.push('\n');
1227 }
1228
1229 if let Some(note) = &spec.options_note {
1230 out.push_str(&wrap_comment(note));
1231 }
1232 let backup = if name == "cursor" { "gemini" } else { "cursor" };
1235 out.push_str("# A stand in for when this CLI refuses, stalls, or runs out of quota.\n");
1236 out.push_str("# It answers in place of this agent, never alongside it.\n");
1237 out.push_str(&format!(
1238 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1239 ));
1240 out.push('\n');
1241 out
1242}
1243
1244fn wrap_comment(text: &str) -> String {
1246 const WIDTH: usize = 76;
1247 let mut out = String::new();
1248 let mut line = String::from("#");
1249 for word in text.split_whitespace() {
1250 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1251 out.push_str(&line);
1252 out.push('\n');
1253 line = String::from("#");
1254 }
1255 line.push(' ');
1256 line.push_str(word);
1257 }
1258 if line.len() > 1 {
1259 out.push_str(&line);
1260 out.push('\n');
1261 }
1262 out
1263}
1264
1265fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1266 let mut ok = true;
1267
1268 let probes: Vec<(&str, Probe)> = vec![
1269 (
1270 "git",
1271 Box::new(|| {
1272 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1273 .map(|s| first_line(&s))
1274 }),
1275 ),
1276 (
1277 "gh",
1278 Box::new(|| {
1279 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1280 .map(|s| first_line(&s))
1281 }),
1282 ),
1283 (
1284 "gh auth",
1285 Box::new(|| {
1286 let out = proc::exec(
1287 &["gh".into(), "auth".into(), "status".into()],
1288 &ExecOpts::new().check(false).timeout_secs(60),
1289 )?;
1290 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1291 if out.ok() {
1292 Ok(first_line(&text))
1293 } else {
1294 Err(spar_err!("not authenticated. Run `gh auth login`."))
1295 }
1296 }),
1297 ),
1298 ];
1299
1300 for (label, probe) in probes {
1301 match probe() {
1302 Ok(detail) => println!(" ok {label:12} {detail}"),
1303 Err(e) => {
1304 println!(" FAIL {label:12} {}", e.first_line());
1305 ok = false;
1306 }
1307 }
1308 }
1309
1310 let found = config::find_config(config_path)?;
1311 let Some(path) = found else {
1312 println!("\n no spar.toml found. Run `spar init` to generate one.");
1313 println!(
1314 " presets available: {}",
1315 config::available_presets().join(", ")
1316 );
1317 return Ok(if ok { 0 } else { 1 });
1318 };
1319
1320 println!("\n config: {}", path.display());
1321 let cfg = match config::load(Some(&path)) {
1322 Ok(cfg) => cfg,
1323 Err(e) => {
1324 println!(" FAIL config {e}");
1325 return Ok(1);
1326 }
1327 };
1328
1329 let mut resolved = Vec::new();
1332 for spec in &cfg.agents {
1333 let agent = Agent::new(spec.clone());
1334 match agent.resolve_bin() {
1335 Ok(bin) => {
1336 println!(
1337 " ok {:12} {} ({})",
1338 spec.name,
1339 bin.display(),
1340 spec.describe()
1341 );
1342 report_fallback(&agent);
1343 resolved.push(agent);
1344 }
1345 Err(e) => {
1346 println!(" FAIL {:12} {}", spec.name, e.first_line());
1347 ok = false;
1348 }
1349 }
1350 }
1351
1352 if resolved.len() == cfg.agents.len() {
1353 if let Some(warning) = agent::correlation_warning(&resolved) {
1354 println!("\n WARNING {warning}");
1355 }
1356 }
1357
1358 println!(
1359 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1360 cfg.loop_cfg.max_rounds,
1361 cfg.loop_cfg.auto_merge,
1362 cfg.loop_cfg.worktrees,
1363 cfg.loop_cfg.followups,
1364 cfg.style.terse
1365 );
1366 if let Ok(text) = std::fs::read_to_string(&path) {
1370 let unset = config::unmentioned_options(&text);
1371 if !unset.is_empty() {
1372 println!(
1373 "\n {} setting(s) this config does not mention, all at their defaults:",
1374 unset.len()
1375 );
1376 for option in &unset {
1377 println!(
1378 " [{}] {} = {}",
1379 option.section, option.key, option.default
1380 );
1381 }
1382 println!(
1383 " `spar init --update {}` appends them as comments.",
1384 path.display()
1385 );
1386 }
1387 }
1388
1389 println!(
1390 "{}",
1391 if ok {
1392 "\nready"
1393 } else {
1394 "\nmissing prerequisites"
1395 }
1396 );
1397 Ok(if ok { 0 } else { 1 })
1398}
1399
1400fn first_line(text: &str) -> String {
1401 text.trim().lines().next().unwrap_or("").trim().to_string()
1402}
1403
1404fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1409 println!("\n{}", "=".repeat(60));
1410 for r in results {
1411 println!(
1412 "#{:<5} {:<10} rounds={} {}",
1413 r.issue,
1414 r.status.to_string(),
1415 r.rounds,
1416 r.pr.as_deref().unwrap_or("")
1417 );
1418 for note in &r.notes {
1419 println!(" {}", first_line(note));
1420 }
1421 for url in &r.filed {
1422 println!(" filed {url}");
1423 }
1424 for dispute in &r.disputes {
1425 println!(" disputed: {}", dispute.title);
1426 }
1427 }
1428 println!("{}", "=".repeat(60));
1429
1430 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1431 println!("\nApproved PRs are waiting on you to merge.");
1432 }
1433 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1434 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1435 println!(
1436 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1437 Set followups = \"issues\" to file them."
1438 );
1439 }
1440 if results.iter().all(IssueRun::succeeded) {
1441 0
1442 } else {
1443 1
1444 }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449 use super::*;
1450 use clap::CommandFactory;
1451
1452 #[test]
1453 fn the_parser_is_internally_consistent() {
1454 Cli::command().debug_assert();
1455 }
1456
1457 #[test]
1458 fn quiet_is_accepted_before_or_after_the_subcommand() {
1459 for argv in [
1460 vec!["spar", "--quiet", "run", "42"],
1461 vec!["spar", "run", "42", "--quiet"],
1462 vec!["spar", "resume", "--quiet"],
1463 vec!["spar", "init", "-q"],
1464 ] {
1465 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1466 }
1467 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1468 }
1469
1470 #[test]
1471 fn several_issue_numbers_are_accepted() {
1472 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1473 match cli.command {
1474 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1475 other => panic!("{other:?}"),
1476 }
1477 }
1478
1479 #[test]
1480 fn issue_numbers_and_flags_can_be_interleaved() {
1481 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1482 match cli.command {
1483 Command::Run {
1484 issues, loop_flags, ..
1485 } => {
1486 assert_eq!(vec![42, 51], issues);
1487 assert!(loop_flags.auto_merge);
1488 }
1489 other => panic!("{other:?}"),
1490 }
1491 }
1492
1493 #[test]
1494 fn every_command_that_reads_a_config_accepts_one() {
1495 for argv in [
1496 vec!["spar", "run", "42"],
1497 vec!["spar", "triage"],
1498 vec!["spar", "resume"],
1499 vec!["spar", "clean"],
1500 vec!["spar", "doctor"],
1501 ] {
1502 let mut full = argv.clone();
1503 full.extend(["--config", "other.toml"]);
1504 let cli = Cli::parse_from(&full);
1505 let config = match cli.command {
1506 Command::Run { common, .. }
1507 | Command::Triage { common, .. }
1508 | Command::Resume { common, .. } => common.config,
1509 Command::Clean { config, .. } | Command::Doctor { config } => config,
1510 other => panic!("{other:?}"),
1511 };
1512 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1513 }
1514 }
1515
1516 #[test]
1517 fn auto_merge_is_off_unless_asked_for() {
1518 let cli = Cli::parse_from(["spar", "run"]);
1519 match cli.command {
1520 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1521 other => panic!("{other:?}"),
1522 }
1523 }
1524
1525 #[test]
1526 fn the_two_close_skipped_flags_are_mutually_exclusive() {
1527 assert!(
1528 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1529 );
1530 }
1531
1532 #[test]
1535 fn close_skipped_is_offered_only_where_it_means_something() {
1536 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1537 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1538 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1539 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1540 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1541 }
1542
1543 #[test]
1544 fn the_close_skipped_pair_resolves_to_a_tristate() {
1545 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1546 Command::Run { triage_flags, .. } => {
1547 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1548 (true, _) => Some(true),
1549 (_, true) => Some(false),
1550 _ => None,
1551 }
1552 }
1553 other => panic!("{other:?}"),
1554 };
1555 assert_eq!(None, read(&["spar", "run"]));
1556 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1557 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1558 }
1559
1560 #[test]
1561 fn the_default_limit_is_twenty() {
1562 let cli = Cli::parse_from(["spar", "run"]);
1563 match cli.command {
1564 Command::Run { common, .. } => assert_eq!(20, common.limit),
1565 other => panic!("{other:?}"),
1566 }
1567 }
1568
1569 #[test]
1570 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1571 assert!(matches!(
1572 Cli::parse_from(["spar", "scrub-filter"]).command,
1573 Command::ScrubFilter
1574 ));
1575 let help = Cli::command().render_long_help().to_string();
1576 assert!(
1577 !help.contains("scrub-filter"),
1578 "it is plumbing, not a command"
1579 );
1580 }
1581
1582 #[test]
1583 fn review_takes_pr_numbers_and_a_dry_run() {
1584 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1585 match cli.command {
1586 Command::Review { items, dry_run, .. } => {
1587 assert_eq!(vec![101, 102], items);
1588 assert!(dry_run);
1589 }
1590 other => panic!("{other:?}"),
1591 }
1592 }
1593
1594 #[test]
1595 fn review_posts_unless_told_not_to() {
1596 match Cli::parse_from(["spar", "review", "101"]).command {
1597 Command::Review { dry_run, .. } => assert!(!dry_run),
1598 other => panic!("{other:?}"),
1599 }
1600 }
1601
1602 #[test]
1603 fn review_with_no_numbers_is_allowed() {
1604 match Cli::parse_from(["spar", "review"]).command {
1605 Command::Review { items, .. } => assert!(items.is_empty()),
1606 other => panic!("{other:?}"),
1607 }
1608 }
1609
1610 #[test]
1611 fn review_takes_its_own_round_budget() {
1612 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1613 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1614 other => panic!("{other:?}"),
1615 }
1616 }
1617
1618 #[test]
1619 fn resume_takes_a_next_override() {
1620 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1621 match cli.command {
1622 Command::Resume {
1623 prs, next_actor, ..
1624 } => {
1625 assert_eq!(vec![108], prs);
1626 assert_eq!(Some("codex".to_string()), next_actor);
1627 }
1628 other => panic!("{other:?}"),
1629 }
1630 }
1631}
1632
1633#[cfg(test)]
1634mod absorb_tests {
1635 use super::*;
1636
1637 #[test]
1638 fn absorb_is_off_unless_asked_for() {
1639 match Cli::parse_from(["spar", "run"]).command {
1640 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1641 other => panic!("{other:?}"),
1642 }
1643 }
1644
1645 #[test]
1646 fn absorb_takes_a_wave_count() {
1647 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1648 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1649 other => panic!("{other:?}"),
1650 }
1651 }
1652
1653 #[test]
1654 fn absorb_is_only_offered_where_issues_are_worked() {
1655 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1656 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1657 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1658 }
1659}
1660
1661#[cfg(test)]
1662mod min_number_tests {
1663 use super::*;
1664
1665 fn read(argv: &[&str]) -> Option<i64> {
1666 match Cli::parse_from(argv).command {
1667 Command::Run { common, .. }
1668 | Command::Triage { common, .. }
1669 | Command::Resume { common, .. }
1670 | Command::Review { common, .. } => common.min_number,
1671 other => panic!("{other:?}"),
1672 }
1673 }
1674
1675 #[test]
1676 fn there_is_no_floor_unless_one_is_asked_for() {
1677 assert_eq!(None, read(&["spar", "run"]));
1678 }
1679
1680 #[test]
1681 fn every_command_that_picks_for_itself_accepts_a_floor() {
1682 for cmd in ["run", "triage", "resume", "review"] {
1683 assert_eq!(
1684 Some(480),
1685 read(&["spar", cmd, "--min-number", "480"]),
1686 "{cmd}"
1687 );
1688 }
1689 }
1690}
1691
1692#[cfg(test)]
1693mod settings_block_tests {
1694 use super::*;
1695
1696 fn written(line: &str) -> String {
1699 let after = line.split_once('=').expect("an assignment").1;
1700 let mut quoted = false;
1701 for (i, c) in after.char_indices() {
1702 match c {
1703 '"' => quoted = !quoted,
1704 '#' if !quoted => return after[..i].trim().to_string(),
1705 _ => {}
1706 }
1707 }
1708 after.trim().to_string()
1709 }
1710
1711 fn line_for(text: &str, key: &str) -> String {
1712 text.lines()
1713 .find(|l| {
1714 let bare = l.trim_start().trim_start_matches('#').trim_start();
1715 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1716 })
1717 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
1718 .to_string()
1719 }
1720
1721 #[test]
1728 fn every_value_it_offers_is_the_default_it_actually_has() {
1729 let text = settings_block("claude");
1730 for option in config::known_options() {
1731 if option.section == "loop.effort_schedule" {
1735 continue;
1736 }
1737 let line = line_for(&text, &option.key);
1738 assert_eq!(
1739 option.default,
1740 written(&line),
1741 "the generated config offers `{}`, but the default is {}",
1742 line.trim(),
1743 option.default
1744 );
1745 }
1746 }
1747
1748 #[test]
1752 fn it_offers_every_option_the_parser_knows_about() {
1753 let text = settings_block("claude");
1754 let missing: Vec<String> = config::unmentioned_options(&text)
1755 .into_iter()
1756 .map(|o| format!("[{}] {}", o.section, o.key))
1757 .collect();
1758 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
1759 }
1760
1761 #[test]
1765 fn every_option_it_offers_can_be_uncommented_and_still_load() {
1766 let mut text = String::from(
1767 "[agents.claude]\ncommand = [\"claude\"]\n\n\
1768 [agents.codex]\ncommand = [\"codex\"]\n\n",
1769 );
1770 for line in settings_block("claude").lines() {
1771 text.push_str(uncomment(line).unwrap_or(line));
1772 text.push('\n');
1773 }
1774 let cfg = config::parse(&text).expect("a config of its own suggestions");
1775 assert_eq!("claude", cfg.first_implementor);
1776 }
1777
1778 fn uncomment(line: &str) -> Option<&str> {
1781 let bare = line.trim_start().strip_prefix('#')?.trim_start();
1782 let key = bare.split_once('=')?.0.trim();
1785 let named = !key.is_empty()
1786 && key
1787 .chars()
1788 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1789 named.then_some(bare)
1790 }
1791
1792 #[test]
1793 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
1794 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
1795 }
1796
1797 #[test]
1800 fn a_wrapped_note_stays_in_its_column() {
1801 let text = settings_block("claude");
1802 let column = text
1803 .lines()
1804 .find(|l| l.starts_with("max_rounds"))
1805 .and_then(|l| l.find('#'))
1806 .expect("a note on max_rounds");
1807 let continuation = text
1808 .lines()
1809 .find(|l| l.starts_with(" ") && l.trim_start().starts_with('#'))
1810 .expect("a wrapped note");
1811 assert_eq!(Some(column), continuation.find('#'));
1812 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text}");
1813 }
1814}