1use std::collections::{BTreeMap, BTreeSet, VecDeque};
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use clap::{Args, Parser, Subcommand};
8
9use crate::agent::{self, Agent};
10use crate::brainstorm;
11use crate::checkin;
12use crate::config::{self, Config};
13use crate::error::Result;
14use crate::followups;
15use crate::model::{Issue, IssueRun, ItemKind, Plan, PlanItem, Status};
16use crate::proc::{self, ExecOpts};
17use crate::repo::{Repo, WriteSummary};
18use crate::review;
19use crate::review_only;
20use crate::spend;
21use crate::split;
22use crate::style;
23use crate::tracker;
24use crate::triage;
25use crate::{bail, log, logdim, logging, logwarn, spar_err};
26
27pub const VERSION: &str = env!("CARGO_PKG_VERSION");
28
29#[derive(Parser, Debug)]
30#[command(
31 name = "spar",
32 version = VERSION,
33 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
34 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
35 pull request converges. Neither agent reviews its own most recent edit.\n\n\
36 Arguments are issue numbers for `triage`, and pull request numbers \
37 for `resume`, `review`, and `checkin`. `run` and `split` take either: `run` \
38 continues any pull request given before it triages the issues, and drops an \
39 issue whose pull request was given too. Omit them and \
40 spar takes everything open, up to --limit. `split` applies that limit once to \
41 issues and once to pull requests. `followup` takes none: it works the queue in \
42 .spar/followups.md, and an entry there has no number to name. `brainstorm` \
43 takes words rather than numbers: the subject to brainstorm, or nothing to \
44 brainstorm the repository itself.",
45 max_term_width = 96
46)]
47pub struct Cli {
48 #[arg(short, long, global = true)]
50 pub quiet: bool,
51
52 #[command(subcommand)]
53 pub command: Command,
54}
55
56#[derive(Subcommand, Debug)]
57pub enum Command {
58 Run {
60 issues: Vec<i64>,
62 #[command(flatten)]
63 common: Common,
64 #[command(flatten)]
65 loop_flags: LoopFlags,
66 #[command(flatten)]
67 triage_flags: TriageFlags,
68 #[arg(long, default_value = "plan.json")]
70 plan_out: PathBuf,
71 #[arg(long)]
73 no_worktrees: bool,
74 },
75
76 Followup {
88 #[command(flatten)]
89 common: Common,
90 #[command(flatten)]
91 loop_flags: LoopFlags,
92 #[command(flatten)]
93 triage_flags: TriageFlags,
94 #[arg(long, value_name = "PATH")]
96 file: Option<PathBuf>,
97 #[arg(long)]
99 screen_only: bool,
100 #[arg(long, conflicts_with = "screen_only")]
102 file_only: bool,
103 #[arg(long, default_value = "plan.json")]
105 plan_out: PathBuf,
106 #[arg(long)]
108 no_worktrees: bool,
109 },
110
111 Triage {
113 issues: Vec<i64>,
115 #[command(flatten)]
116 common: Common,
117 #[arg(long, default_value = "plan.json")]
118 plan_out: PathBuf,
119 },
120
121 Resume {
123 prs: Vec<i64>,
125 #[command(flatten)]
126 common: Common,
127 #[command(flatten)]
128 loop_flags: LoopFlags,
129 #[arg(long = "next", value_name = "AGENT")]
131 next_actor: Option<String>,
132 },
133
134 Checkin {
142 items: Vec<i64>,
145 #[command(flatten)]
146 common: Common,
147 #[arg(long)]
149 dry_run: bool,
150 #[arg(long)]
152 reply_only: bool,
153 #[arg(long)]
156 any_author: bool,
157 #[arg(long)]
159 again: bool,
160 #[arg(long)]
162 keep_worktrees: bool,
163 },
164
165 Review {
170 items: Vec<i64>,
173 #[command(flatten)]
174 common: Common,
175 #[arg(long)]
177 dry_run: bool,
178 #[arg(long)]
181 max_rounds: Option<u32>,
182 },
183
184 Brainstorm {
196 topic: Vec<String>,
199 #[command(flatten)]
200 common: Common,
201 #[arg(long)]
205 max_rounds: Option<u32>,
206 #[arg(long, default_value_t = 5, value_name = "N")]
208 ideas: usize,
209 #[arg(long, value_name = "PATH")]
211 out: Option<PathBuf>,
212 #[arg(long)]
214 file_issues: bool,
215 #[arg(
218 long,
219 value_name = "PATH",
220 requires = "file_issues",
221 conflicts_with = "out"
222 )]
223 from: Option<PathBuf>,
224 },
225
226 Split {
243 items: Vec<i64>,
246 #[command(flatten)]
247 common: Common,
248 #[arg(long)]
250 dry_run: bool,
251 #[arg(long)]
254 again: bool,
255 },
256
257 Post {
262 #[arg(required = true)]
264 prs: Vec<i64>,
265 #[arg(long, default_value = ".")]
266 repo: PathBuf,
267 #[arg(long)]
268 config: Option<PathBuf>,
269 #[arg(long, value_name = "PATH")]
271 file: Option<PathBuf>,
272 #[arg(long)]
274 dry_run: bool,
275 },
276
277 Init {
282 #[arg(long, default_value = "spar.toml")]
283 out: PathBuf,
284 #[arg(long)]
286 force: bool,
287 #[arg(long, conflicts_with = "force")]
290 update: bool,
291 },
292
293 Clean {
295 #[arg(long, default_value = ".")]
296 repo: PathBuf,
297 #[arg(long)]
298 config: Option<PathBuf>,
299 #[arg(long)]
302 all: bool,
303 #[arg(long)]
305 pr_state: bool,
306 },
307
308 Doctor {
310 #[arg(long)]
311 config: Option<PathBuf>,
312 },
313
314 #[command(hide = true)]
318 ScrubFilter,
319}
320
321#[derive(Args, Debug, Clone)]
322pub struct Common {
323 #[arg(long, default_value = ".")]
325 pub repo: PathBuf,
326 #[arg(long)]
328 pub config: Option<PathBuf>,
329 #[arg(long)]
331 pub base: Option<String>,
332 #[arg(long)]
334 pub first: Option<String>,
335 #[arg(long, default_value_t = 20)]
338 pub limit: usize,
339 #[arg(long, value_name = "N")]
342 pub min_number: Option<i64>,
343 #[arg(long, value_name = "TEXT")]
346 pub instructions: Option<String>,
347}
348
349#[derive(Args, Debug, Clone)]
350pub struct LoopFlags {
351 #[arg(long)]
354 pub max_rounds: Option<u32>,
355 #[arg(long)]
357 pub auto_merge: bool,
358 #[arg(long)]
360 pub keep_worktrees: bool,
361 #[arg(long, value_name = "N")]
364 pub absorb: Option<u32>,
365}
366
367#[derive(Args, Debug, Clone)]
370pub struct TriageFlags {
371 #[arg(long, conflicts_with = "no_close_skipped")]
373 pub close_skipped: bool,
374 #[arg(long)]
376 pub no_close_skipped: bool,
377 #[arg(long)]
383 pub retriage: bool,
384}
385
386pub fn main() -> i32 {
391 let cli = Cli::parse();
392 logging::init_color();
393 logging::set_quiet(cli.quiet);
394
395 match dispatch(cli) {
396 Ok(code) => code,
397 Err(e) => {
398 logging::error(e.to_string());
399 2
400 }
401 }
402}
403
404fn dispatch(cli: Cli) -> Result<i32> {
405 match cli.command {
406 Command::ScrubFilter => cmd_scrub_filter(),
407 Command::Doctor { config } => cmd_doctor(config.as_deref()),
408 Command::Review {
409 items,
410 common,
411 dry_run,
412 max_rounds,
413 } => {
414 let overrides = Overrides {
415 max_rounds,
416 ..Overrides::default()
417 };
418 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
419 let numbers = if items.is_empty() {
420 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
421 if found.is_empty() {
422 log!("no open PRs");
423 return Ok(0);
424 }
425 log!("no PRs given, reviewing {} open", found.len());
426 found
427 } else {
428 items
429 };
430 let sorted = classify(&repo, &numbers)?;
431 let mut targets = sorted.prs;
432 for number in sorted.issues {
433 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
434 Ok(Some(pr)) => {
435 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
436 targets.push(pr.number);
437 }
438 Ok(None) => {
439 logwarn!("#{number} is an issue with no open pull request to review")
440 }
441 Err(e) => logwarn!("#{number}: {e}"),
442 }
443 }
444 let mut results = Vec::new();
445 for number in targets {
446 results.push(review_only::review_pr(
447 &agents, &cfg, &repo, number, dry_run,
448 ));
449 }
450 if results.is_empty() {
451 return Ok(0);
452 }
453 Ok(report(&results, &cfg, &repo))
454 }
455
456 Command::Brainstorm {
457 topic,
458 common,
459 max_rounds,
460 ideas,
461 out,
462 file_issues,
463 from,
464 } => cmd_brainstorm(
465 topic,
466 &common,
467 max_rounds,
468 ideas,
469 out,
470 file_issues,
471 from.as_deref(),
472 ),
473
474 Command::Checkin {
475 items,
476 common,
477 dry_run,
478 reply_only,
479 any_author,
480 again,
481 keep_worktrees,
482 } => {
483 let overrides = Overrides {
484 keep_worktrees: keep_worktrees.then_some(true),
485 ..Overrides::default()
486 };
487 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
488 let mode = checkin::Mode {
489 dry_run,
490 reply_only,
491 trust: if any_author {
492 crate::config::Trust::Anyone
493 } else {
494 cfg.loop_cfg.checkin_trust
495 },
496 again,
497 resolve: cfg.loop_cfg.checkin_resolve,
498 posts: checkin::posts(&cfg),
499 };
500 let numbers = if items.is_empty() {
501 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
502 if found.is_empty() {
503 log!("no open PRs");
504 return Ok(0);
505 }
506 log!(
507 "no PRs given, checking in on {} open: {}",
508 found.len(),
509 found
510 .iter()
511 .map(|n| format!("#{n}"))
512 .collect::<Vec<_>>()
513 .join(", ")
514 );
515 found
516 } else {
517 items
518 };
519 let sorted = classify(&repo, &numbers)?;
520 let mut results = Vec::new();
521 for number in sorted.prs {
522 results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
523 }
524 for number in sorted.issues {
528 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
529 Ok(Some(pr)) => {
530 log!(
531 "#{number} is an issue; checking in on its open PR {}",
532 pr.url
533 );
534 results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
535 }
536 Ok(None) => {
537 results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
538 }
539 Err(e) => results.push(checkin::failed(number, format!("#{number}"), e)),
544 }
545 }
546 if results.is_empty() {
547 return Ok(0);
548 }
549 Ok(report(&results, &cfg, &repo))
550 }
551
552 Command::Split {
553 items,
554 common,
555 dry_run,
556 again,
557 } => {
558 let (cfg, repo, agents) = prepare(&common, None)?;
559 let mode = split::Mode { dry_run, again };
560 let picked = if items.is_empty() {
561 pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
562 } else {
563 let sorted = classify(&repo, &items)?;
564 let mut out: Vec<(i64, ItemKind)> = sorted
565 .issues
566 .iter()
567 .map(|n| (*n, ItemKind::Issue))
568 .collect();
569 out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
570 out
571 };
572
573 let mut results = Vec::new();
574 let mut seen: BTreeSet<i64> = BTreeSet::new();
575 for (number, kind) in picked {
576 let target = match kind {
580 ItemKind::Pr => Some(number),
581 ItemKind::Issue => {
582 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
583 Ok(found) => found.map(|pr| {
584 log!("#{number} is an issue; splitting its open PR {}", pr.url);
585 pr.number
586 }),
587 Err(e) => {
588 logwarn!("#{number}: {e}");
589 continue;
590 }
591 }
592 }
593 };
594 if !seen.insert(target.unwrap_or(number)) {
599 logdim!("#{number} was already covered by this run, skipping it");
600 continue;
601 }
602 results.push(match target {
603 Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
604 None => split::split_issue(&agents, &cfg, &repo, number, &mode),
605 });
606 }
607 if results.is_empty() {
608 return Ok(0);
609 }
610 Ok(report(&results, &cfg, &repo))
611 }
612
613 Command::Post {
614 prs,
615 repo: repo_path,
616 config,
617 file,
618 dry_run,
619 } => cmd_post(
620 &prs,
621 &repo_path,
622 config.as_deref(),
623 file.as_deref(),
624 dry_run,
625 ),
626
627 Command::Init { out, force, update } => {
628 if update {
629 cmd_init_update(&out)
630 } else {
631 cmd_init(&out, force)
632 }
633 }
634 Command::Clean {
635 repo,
636 config,
637 all,
638 pr_state,
639 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
640 Command::Triage {
641 issues,
642 common,
643 plan_out,
644 } => {
645 let (cfg, repo, agents) = prepare(&common, None)?;
646 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
647 if numbers.is_empty() {
648 return Ok(0);
649 }
650 let sorted = classify(&repo, &numbers)?;
651 for number in &sorted.prs {
652 log!("#{number} is a pull request, nothing to triage");
653 }
654 if sorted.issues.is_empty() {
655 log!("no issues to triage");
656 return Ok(0);
657 }
658 let issues = repo.fetch_issues(&sorted.issues)?;
659 let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
663 if cfg.loop_cfg.decompose_trackers {
667 for item in plan.skipped.iter().filter(|i| i.tracker) {
668 tracker::preview(&cfg, &repo, item.issue);
669 }
670 }
671 Ok(0)
672 }
673 Command::Run {
674 issues,
675 common,
676 loop_flags,
677 triage_flags,
678 plan_out,
679 no_worktrees,
680 } => {
681 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
682 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
683 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
684 if numbers.is_empty() {
685 return Ok(0);
686 }
687 let sorted = classify(&repo, &numbers)?;
688 let mut results = Vec::new();
689 let mut stopped = Vec::new();
690 let mut parked = Vec::new();
691
692 let mut issues = sorted.issues.clone();
698 if !sorted.prs.is_empty() {
699 log!(
700 "continuing {} pull request(s) before starting new work: {}",
701 sorted.prs.len(),
702 self::numbers(&sorted.prs)
703 );
704 let covered = issues_covered_by(&repo, &sorted.prs);
705 for (issue, pr) in covered.iter().filter(|(i, _)| issues.contains(i)) {
706 log!(
707 "#{issue} is what PR #{pr} closes, and both were given, so it is \
708 continued there rather than implemented again"
709 );
710 }
711 issues = issues_not_on_named_prs(&issues, &covered);
712 for number in &sorted.prs {
713 results.push(review::resume_pr(&agents, &cfg, &repo, *number, None));
714 }
715 }
716
717 if let Err(e) = work_issues(
718 &agents,
719 &cfg,
720 &repo,
721 issues,
722 &plan_out,
723 &mut results,
724 &mut stopped,
725 &mut parked,
726 triage_flags.retriage,
727 ) {
728 if results.is_empty() {
731 return Err(e);
732 }
733 stopped.push(format!("the issue queue stopped: {e}"));
734 }
735
736 if results.is_empty() {
737 log!("nothing scheduled");
740 for line in parked.iter().chain(stopped.iter()) {
741 println!("{line}");
742 }
743 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
744 }
745 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
746 }
747 Command::Followup {
748 common,
749 loop_flags,
750 triage_flags,
751 file,
752 screen_only,
753 file_only,
754 plan_out,
755 no_worktrees,
756 } => {
757 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
758 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
759 let path = file.unwrap_or_else(|| repo.followups_path());
760 let mode = match (screen_only, file_only) {
761 (true, _) => followups::Mode::ScreenOnly,
762 (_, true) => followups::Mode::FileOnly,
763 _ => followups::Mode::Work,
764 };
765 let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
766
767 let wave = followups::wave(&outcome);
768 if mode != followups::Mode::Work || wave.is_empty() {
769 return Ok(report_writes(&repo));
770 }
771 let mut results = Vec::new();
772 let mut stopped = Vec::new();
773 let mut parked = Vec::new();
774 work_issues(
775 &agents,
776 &cfg,
777 &repo,
778 wave,
779 &plan_out,
780 &mut results,
781 &mut stopped,
782 &mut parked,
783 triage_flags.retriage,
784 )?;
785 if results.is_empty() {
786 log!("nothing scheduled");
789 for line in parked.iter().chain(stopped.iter()) {
790 println!("{line}");
791 }
792 return Ok(report_writes(&repo).max(i32::from(!stopped.is_empty())));
793 }
794 Ok(report_with(&results, &cfg, &repo, &stopped, &parked))
795 }
796
797 Command::Resume {
798 prs,
799 common,
800 loop_flags,
801 next_actor,
802 } => {
803 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
804 if let Some(name) = &next_actor {
805 if !cfg.has_agent(name) {
806 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
807 }
808 }
809 let numbers = if prs.is_empty() {
810 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
811 if found.is_empty() {
812 log!("no open PRs");
813 return Ok(0);
814 }
815 log!(
816 "no PRs given, taking {} open: {}",
817 found.len(),
818 found
819 .iter()
820 .map(|n| format!("#{n}"))
821 .collect::<Vec<_>>()
822 .join(", ")
823 );
824 found
825 } else {
826 prs
827 };
828 let sorted = classify(&repo, &numbers)?;
829 let mut results = Vec::new();
830 for number in sorted.prs {
831 results.push(review::resume_pr(
832 &agents,
833 &cfg,
834 &repo,
835 number,
836 next_actor.as_deref(),
837 ));
838 }
839 for number in sorted.issues {
842 match repo.try_open_pr_for_issue(number, cfg.base_branch()) {
843 Ok(Some(pr)) => {
844 log!("#{number} is an issue; continuing its open PR {}", pr.url);
845 results.push(review::resume_pr(
846 &agents,
847 &cfg,
848 &repo,
849 pr.number,
850 next_actor.as_deref(),
851 ));
852 }
853 Ok(None) => logwarn!(
854 "#{number} is an issue with no open pull request. Use `spar run {number}` \
855 to implement it."
856 ),
857 Err(e) => logwarn!("#{number}: {e}"),
858 }
859 }
860 if results.is_empty() {
861 return Ok(0);
862 }
863 Ok(report(&results, &cfg, &repo))
864 }
865 }
866}
867
868#[derive(Debug, Default, Clone)]
873struct Overrides {
874 max_rounds: Option<u32>,
875 auto_merge: Option<bool>,
876 keep_worktrees: Option<bool>,
877 worktrees: Option<bool>,
878 close_skipped: Option<bool>,
879 absorb: Option<u32>,
880}
881
882impl From<&LoopFlags> for Overrides {
883 fn from(flags: &LoopFlags) -> Self {
884 Self {
885 max_rounds: flags.max_rounds,
886 auto_merge: flags.auto_merge.then_some(true),
887 keep_worktrees: flags.keep_worktrees.then_some(true),
888 worktrees: None,
889 close_skipped: None,
890 absorb: flags.absorb,
891 }
892 }
893}
894
895impl Overrides {
896 fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
899 let mut over = Overrides::from(loop_flags);
900 over.worktrees = if no_worktrees { Some(false) } else { None };
901 over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
902 (true, _) => Some(true),
903 (_, true) => Some(false),
904 _ => None,
905 };
906 over
907 }
908}
909
910#[allow(clippy::too_many_arguments)]
920fn work_issues(
921 agents: &[Agent],
922 cfg: &Config,
923 repo: &Repo,
924 first_wave: Vec<i64>,
925 plan_out: &Path,
926 results: &mut Vec<IssueRun>,
927 stopped: &mut Vec<String>,
928 parked: &mut Vec<String>,
929 retriage: bool,
930) -> Result<()> {
931 let waiting = repo.read_contested();
935 if retriage && !waiting.is_empty() {
936 repo.forget_contested(&waiting.keys().copied().collect::<Vec<_>>());
937 }
938 let mut handled: BTreeSet<i64> = BTreeSet::new();
939 let mut landed: BTreeSet<i64> = BTreeSet::new();
943 let mut leftover: BTreeSet<i64> = BTreeSet::new();
944 let mut worked = 0usize;
948 let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
949 let mut plans_written = 0usize;
950
951 while let Some(mut wave) = queue.pop_front() {
958 wave.numbers.retain(|n| !handled.contains(n));
959 if !retriage {
960 wave.numbers.retain(|n| {
961 let Some(known) = waiting.get(n) else {
962 return true;
963 };
964 log!(
965 "#{n} was contested on an earlier run and is waiting on you: {}. Pass \
966 --retriage to ask again.",
967 positions_of(known)
968 );
969 parked.push(format!(
970 "#{n} contested and parked for you: {}",
971 positions_of(known)
972 ));
973 false
974 });
975 }
976 if wave.numbers.is_empty() {
977 continue;
978 }
979 match wave.parent {
980 Some(tracker) => log!(
981 "working {} item(s) from the checklist in #{tracker}: {}",
982 wave.numbers.len(),
983 numbers(&wave.numbers)
984 ),
985 None if wave.absorbed > 0 => log!(
986 "absorbing {} newly filed issue(s): {}",
987 wave.numbers.len(),
988 numbers(&wave.numbers)
989 ),
990 None => {}
991 }
992 handled.extend(wave.numbers.iter().copied());
993
994 let fetched = match repo.fetch_issues(&wave.numbers) {
995 Ok(fetched) => fetched,
996 Err(e) => {
997 logdim!("could not read the next wave: {e}");
998 continue;
999 }
1000 };
1001 let plan_path = if plans_written == 0 {
1004 plan_out.to_path_buf()
1005 } else {
1006 plan_out.with_extension(format!("wave{plans_written}.json"))
1007 };
1008 plans_written += 1;
1009 let plan = match make_plan(agents, cfg, repo, &fetched, &plan_path) {
1014 Ok(plan) => plan,
1015 Err(e) => {
1016 logwarn!("triage failed for {}: {e}", numbers(&wave.numbers));
1017 stopped.push(format!(
1018 "triage failed for {}, so it was not worked: {}",
1019 numbers(&wave.numbers),
1020 first_line(&e.to_string())
1021 ));
1022 for later in &queue {
1023 stopped.push(format!(
1024 "{} was not reached after that failure",
1025 numbers(&later.numbers)
1026 ));
1027 }
1028 queue.clear();
1029 break;
1030 }
1031 };
1032 act_on_plan(cfg, repo, &plan);
1033 repo.remember_contested(&plan.contested);
1034 for item in &plan.contested {
1035 parked.push(format!(
1036 "#{} contested and parked for you: {}",
1037 item.issue,
1038 positions(&item.positions, &item.reasons)
1039 ));
1040 }
1041 for item in &plan.skipped {
1042 let what = if item.tracker {
1043 "left open as a tracker"
1044 } else if cfg.loop_cfg.close_skipped {
1045 "commented on and closed"
1046 } else {
1047 "commented on and left open"
1048 };
1049 parked.push(format!(
1050 "#{} skipped by both reviewers, {what}: {}",
1051 item.issue,
1052 item.reasons
1053 .values()
1054 .next()
1055 .map(|r| first_line(r))
1056 .unwrap_or_default()
1057 ));
1058 }
1059
1060 if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
1064 for item in plan.skipped.iter().filter(|i| i.tracker) {
1065 let children = tracker::decompose(cfg, repo, item.issue);
1066 if !children.is_empty() {
1067 queue.push_back(wave.child(item.issue, children));
1068 }
1069 }
1070 }
1071
1072 let before = results.len();
1073 for item in &plan.order {
1074 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
1075 continue;
1076 };
1077 if let Some(why) = held_back(item, &plan, &landed) {
1078 log!("#{}: {why}", item.issue);
1079 let mut held = IssueRun::new(item.issue, item.title.clone());
1080 held.status = Status::Pending;
1081 held.notes.push(why);
1082 results.push(held);
1083 continue;
1084 }
1085 let run = review::run_nth_issue(agents, cfg, repo, item, issue, worked);
1086 worked += 1;
1087 if run.status == Status::Merged {
1088 landed.insert(item.issue);
1089 }
1090 results.push(run);
1091 }
1092
1093 let filed: Vec<i64> = results[before..]
1095 .iter()
1096 .flat_map(|r| r.filed.iter())
1097 .filter_map(|url| review::filed_issue_number(url))
1098 .filter(|n| !handled.contains(n))
1099 .collect::<BTreeSet<_>>()
1100 .into_iter()
1101 .collect();
1102 if filed.is_empty() {
1103 continue;
1104 }
1105 match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
1106 true => queue.push_back(wave.absorb(filed)),
1107 false => leftover.extend(filed),
1108 }
1109 }
1110 if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
1111 log!(
1112 "{} issue(s) filed in the last wave were left for a later run: {}",
1113 leftover.len(),
1114 numbers(&leftover.iter().copied().collect::<Vec<_>>())
1115 );
1116 }
1117 Ok(())
1118}
1119
1120fn positions(
1122 positions: &std::collections::BTreeMap<String, String>,
1123 reasons: &std::collections::BTreeMap<String, String>,
1124) -> String {
1125 if positions.is_empty() {
1126 return "no positions recorded".to_string();
1127 }
1128 positions
1129 .iter()
1130 .map(|(name, side)| match reasons.get(name) {
1131 Some(why) if !why.trim().is_empty() => {
1132 format!("{name} says {side} ({})", first_line(why))
1133 }
1134 _ => format!("{name} says {side}"),
1135 })
1136 .collect::<Vec<_>>()
1137 .join("; ")
1138}
1139
1140fn positions_of(known: &crate::model::Contested) -> String {
1141 positions(&known.positions, &known.reasons)
1142}
1143
1144fn held_back(item: &PlanItem, plan: &Plan, landed: &BTreeSet<i64>) -> Option<String> {
1156 for dep in &item.depends_on {
1157 if landed.contains(dep) {
1158 continue;
1159 }
1160 if plan.order.iter().any(|other| other.issue == *dep) {
1161 return Some(format!(
1162 "held back: #{dep} is in this run and has not merged, so its work is not on the \
1163 base this would be built from. Merge #{dep} and run #{} again.",
1164 item.issue
1165 ));
1166 }
1167 if plan.skipped.iter().any(|s| s.issue == *dep) {
1168 return Some(format!(
1169 "held back: #{dep}, which it depends on, was declined by both reviewers"
1170 ));
1171 }
1172 if plan.contested.iter().any(|c| c.issue == *dep) {
1173 return Some(format!(
1174 "held back: the reviewers disagree about #{dep}, which it depends on"
1175 ));
1176 }
1177 }
1178 None
1179}
1180
1181struct Wave {
1183 numbers: Vec<i64>,
1184 absorbed: u32,
1189 parent: Option<i64>,
1191}
1192
1193impl Wave {
1194 fn first(numbers: Vec<i64>) -> Self {
1195 Self {
1196 numbers,
1197 absorbed: 0,
1198 parent: None,
1199 }
1200 }
1201
1202 fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
1203 Self {
1204 numbers,
1205 absorbed: self.absorbed,
1206 parent: Some(tracker),
1207 }
1208 }
1209
1210 fn absorb(&self, numbers: Vec<i64>) -> Self {
1211 Self {
1212 numbers,
1213 absorbed: self.absorbed + 1,
1214 parent: None,
1215 }
1216 }
1217}
1218
1219fn numbers(items: &[i64]) -> String {
1220 items
1221 .iter()
1222 .map(|n| format!("#{n}"))
1223 .collect::<Vec<_>>()
1224 .join(", ")
1225}
1226
1227fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
1228 let mut cfg = config::load(common.config.as_deref())?;
1229
1230 if let Some(first) = &common.first {
1231 if !cfg.has_agent(first) {
1232 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
1233 }
1234 cfg.first_implementor = first.clone();
1235 }
1236 if let Some(base) = &common.base {
1237 cfg.loop_cfg.base_branch = base.clone();
1238 }
1239 if let Some(min) = common.min_number {
1240 cfg.loop_cfg.min_number = min;
1241 }
1242 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
1246 if !extra.is_empty() {
1247 let standing = cfg.loop_cfg.instructions.trim();
1248 cfg.loop_cfg.instructions = if standing.is_empty() {
1249 extra.to_string()
1250 } else {
1251 format!("{standing}\n{extra}")
1252 };
1253 }
1254 }
1255 if let Some(over) = overrides {
1256 if let Some(v) = over.max_rounds {
1257 if v == 0 {
1258 bail!("--max-rounds must be at least 1");
1259 }
1260 cfg.loop_cfg.max_rounds = v;
1261 }
1262 if let Some(v) = over.auto_merge {
1263 cfg.loop_cfg.auto_merge = v;
1264 }
1265 if let Some(v) = over.keep_worktrees {
1266 cfg.loop_cfg.keep_worktrees = v;
1267 }
1268 if let Some(v) = over.worktrees {
1269 cfg.loop_cfg.worktrees = v;
1270 }
1271 if let Some(v) = over.close_skipped {
1272 cfg.loop_cfg.close_skipped = v;
1273 }
1274 if let Some(v) = over.absorb {
1275 cfg.loop_cfg.absorb_new_issues = v;
1276 }
1277 }
1278
1279 let repo = Repo::open(&common.repo, &cfg)?;
1280 if common.base.is_none() {
1281 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
1282 }
1283
1284 let agents = agent::build(&cfg)?;
1285 if let Some(warning) = agent::correlation_warning(&agents) {
1286 logging::warn(warning);
1287 }
1288
1289 for stale in repo.prune_worktrees(false) {
1291 let what = if stale.starts_with("branch ") {
1292 stale
1293 } else {
1294 format!("worktree {stale}")
1295 };
1296 logdim!("cleaned up finished {what}");
1297 }
1298
1299 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
1300 log!(
1301 "agents: {}, first {}",
1302 agents
1303 .iter()
1304 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1305 .collect::<Vec<_>>()
1306 .join(", "),
1307 cfg.first_implementor_policy()
1308 );
1309 Ok((cfg, repo, agents))
1310}
1311
1312fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1313 if !given.is_empty() {
1314 if min_number > 0 {
1316 let below: Vec<String> = given
1317 .iter()
1318 .filter(|n| **n < min_number)
1319 .map(|n| format!("#{n}"))
1320 .collect();
1321 if !below.is_empty() {
1322 logdim!(
1323 "{} below the #{min_number} floor, taking them because you named them",
1324 below.join(", ")
1325 );
1326 }
1327 }
1328 return Ok(given);
1329 }
1330 let found = repo.list_open_issues(limit, min_number)?;
1331 if found.is_empty() {
1332 log!("no open issues");
1333 return Ok(found);
1334 }
1335 log!(
1336 "no issues given, taking {} open: {}",
1337 found.len(),
1338 found
1339 .iter()
1340 .map(|n| format!("#{n}"))
1341 .collect::<Vec<_>>()
1342 .join(", ")
1343 );
1344 Ok(found)
1345}
1346
1347fn pick_for_split(
1360 agents: &[Agent],
1361 cfg: &Config,
1362 repo: &Repo,
1363 limit: usize,
1364 mode: &split::Mode,
1365) -> Result<Vec<(i64, ItemKind)>> {
1366 let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1367 let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1368 log!(
1369 "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1370 issues.len(),
1371 prs.len()
1372 );
1373 if issues.is_empty() && prs.is_empty() {
1374 return Ok(Vec::new());
1375 }
1376
1377 let issue_rows = repo.open_issue_rows();
1378 let pr_rows = repo.open_pr_rows();
1379 let mut candidates: Vec<split::Candidate> = Vec::new();
1380 let mut already = 0usize;
1381
1382 for number in &issues {
1383 let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1384 logdim!("could not read #{number}, leaving it alone");
1385 continue;
1386 };
1387 if split::already_split(row.body_text()) && !mode.again {
1390 already += 1;
1391 continue;
1392 }
1393 candidates.push(split::Candidate::from_issue(row));
1394 }
1395 for number in &prs {
1396 match pr_rows.iter().find(|p| p.number == *number) {
1397 Some(row) => candidates.push(split::Candidate::from_pr(row)),
1398 None => logdim!("could not read PR #{number}, leaving it alone"),
1399 }
1400 }
1401 if already > 0 {
1402 log!("{already} issue(s) already split, skipped. --again reopens them.");
1403 }
1404 if candidates.is_empty() {
1405 return Ok(Vec::new());
1406 }
1407
1408 let agent = agent::find(agents, &cfg.first_implementor)?;
1409 log!(
1410 "screening {} item(s) with {}",
1411 candidates.len(),
1412 agent.name()
1413 );
1414 let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1415
1416 let mut picked = Vec::new();
1417 for candidate in &candidates {
1418 match verdicts.iter().find(|v| v.item == candidate.number) {
1419 Some(v) if v.split => {
1420 log!(" split #{}: {}", candidate.number, v.reason.trim());
1421 picked.push((candidate.number, candidate.kind));
1422 }
1423 Some(v) => logdim!(" leave #{} whole: {}", candidate.number, v.reason.trim()),
1426 None => logdim!(" no verdict for #{}, leaving it whole", candidate.number),
1427 }
1428 }
1429 if picked.is_empty() {
1430 log!("nothing worth splitting");
1431 }
1432 Ok(picked)
1433}
1434
1435#[derive(Debug, Default)]
1441struct Sorted {
1442 issues: Vec<i64>,
1443 prs: Vec<i64>,
1444}
1445
1446fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1447 let mut sorted = Sorted::default();
1448 for number in numbers {
1449 match repo.item_kind(*number)? {
1450 ItemKind::Issue => sorted.issues.push(*number),
1451 ItemKind::Pr => sorted.prs.push(*number),
1452 }
1453 }
1454 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1455 log!(
1456 "{} issue(s) and {} pull request(s) given",
1457 sorted.issues.len(),
1458 sorted.prs.len()
1459 );
1460 }
1461 Ok(sorted)
1462}
1463
1464fn issues_covered_by(repo: &Repo, prs: &[i64]) -> BTreeMap<i64, i64> {
1477 let mut covered = BTreeMap::new();
1478 for number in prs {
1479 match repo.pr_view(*number) {
1480 Ok(view) => {
1481 for issue in &view.closing_issues_references {
1482 covered.insert(issue.number, *number);
1483 }
1484 }
1485 Err(e) => logwarn!(
1486 "#{number}: could not read which issue(s) it closes, so none are skipped: {}",
1487 e.last_line()
1488 ),
1489 }
1490 }
1491 covered
1492}
1493
1494fn issues_not_on_named_prs(issues: &[i64], covered: &BTreeMap<i64, i64>) -> Vec<i64> {
1497 issues
1498 .iter()
1499 .copied()
1500 .filter(|issue| !covered.contains_key(issue))
1501 .collect()
1502}
1503
1504fn make_plan(
1505 agents: &[Agent],
1506 cfg: &Config,
1507 repo: &Repo,
1508 issues: &[Issue],
1509 plan_out: &Path,
1510) -> Result<Plan> {
1511 let plan = triage::triage(agents, cfg, repo, issues)?;
1512
1513 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1514 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1515 log!("plan written to {}", plan_out.display());
1516
1517 for item in &plan.order {
1518 let after = if item.depends_on.is_empty() {
1521 String::new()
1522 } else {
1523 format!(
1524 " after {}",
1525 item.depends_on
1526 .iter()
1527 .map(|n| format!("#{n}"))
1528 .collect::<Vec<_>>()
1529 .join(", ")
1530 )
1531 };
1532 log!(
1533 " do #{} [{}/{}]{after} {}",
1534 item.issue,
1535 item.complexity,
1536 item.risk,
1537 item.title
1538 );
1539 }
1540 for item in &plan.skipped {
1541 if item.tracker {
1542 log!(
1543 " hold #{} (both reviewers: tracks work filed elsewhere)",
1544 item.issue
1545 );
1546 } else {
1547 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
1548 }
1549 }
1550 for item in &plan.contested {
1551 log!(" ?? #{} contested, parked for you to decide", item.issue);
1552 }
1553 Ok(plan)
1554}
1555
1556fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1566 for item in &plan.skipped {
1567 let body = review::skip_comment(item, &repo.style);
1568 let close = cfg.loop_cfg.close_skipped && !item.tracker;
1569 let outcome = if close {
1570 repo.close_issue(item.issue, &body)
1571 } else {
1572 repo.comment_issue(item.issue, &body)
1573 };
1574 match outcome {
1575 Ok(()) if close => log!(" closed #{}", item.issue),
1576 Ok(()) if item.tracker => {
1577 log!(
1578 " left #{} open, it tracks work filed elsewhere",
1579 item.issue
1580 )
1581 }
1582 Ok(()) => {}
1583 Err(e) => logdim!("could not update #{}: {e}", item.issue),
1584 }
1585 }
1586}
1587
1588fn cmd_scrub_filter() -> Result<i32> {
1593 let mut input = String::new();
1594 std::io::stdin()
1595 .read_to_string(&mut input)
1596 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1597 let out = style::scrub(&input, &crate::repo::style_from_env());
1598 let mut stdout = std::io::stdout();
1599 stdout
1600 .write_all(out.as_bytes())
1601 .and_then(|_| stdout.write_all(b"\n"))
1602 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1603 Ok(0)
1604}
1605
1606fn cmd_clean(
1607 repo_path: &Path,
1608 config_path: Option<&Path>,
1609 all: bool,
1610 pr_state: bool,
1611) -> Result<i32> {
1612 let cfg = config::load(config_path)?;
1613 let repo = Repo::open(repo_path, &cfg)?;
1614 let mut removed = repo.prune_worktrees(all);
1615 removed.extend(repo.prune_state());
1616 if pr_state {
1617 removed.extend(repo.prune_pr_state(None));
1618 }
1619 if removed.is_empty() {
1620 if repo.write_summary().failed > 0 {
1621 println!("nothing removed");
1622 } else {
1623 println!("nothing to clean");
1624 }
1625 } else {
1626 for item in removed {
1627 println!("removed {item}");
1628 }
1629 }
1630 Ok(report_writes(&repo))
1631}
1632
1633fn cmd_post(
1635 prs: &[i64],
1636 repo_path: &Path,
1637 config_path: Option<&Path>,
1638 file: Option<&Path>,
1639 dry_run: bool,
1640) -> Result<i32> {
1641 let cfg = config::load(config_path)?;
1642 let repo = Repo::open(repo_path, &cfg)?;
1643
1644 if file.is_some() && prs.len() > 1 {
1645 bail!("--file posts one review, so give it one pull request number");
1646 }
1647
1648 let mut failed = false;
1649 for number in prs {
1650 let text = match file {
1651 Some(path) => std::fs::read_to_string(path)
1652 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1653 None => match repo.read_pending_comment(*number) {
1654 Some(text) => text,
1655 None => {
1656 logging::error(format!(
1657 "no saved review for PR #{number}. `spar review {number} --dry-run` \
1658 produces one, or pass --file."
1659 ));
1660 failed = true;
1661 continue;
1662 }
1663 },
1664 };
1665 if text.trim().is_empty() {
1666 logging::error(format!("the saved review for PR #{number} is empty"));
1667 failed = true;
1668 continue;
1669 }
1670 if dry_run {
1671 println!("\n{}\n", text.trim());
1672 log!("would post the above to PR #{number}");
1673 continue;
1674 }
1675 match repo.comment_pr(*number, &text) {
1678 Ok(()) => log!("posted to PR #{number}"),
1679 Err(e) => {
1680 logging::error(format!("could not post to PR #{number}: {e}"));
1681 failed = true;
1682 }
1683 }
1684 }
1685 Ok(i32::from(failed).max(report_writes(&repo)))
1686}
1687
1688#[allow(clippy::too_many_arguments)]
1694fn cmd_brainstorm(
1695 topic: Vec<String>,
1696 common: &Common,
1697 max_rounds: Option<u32>,
1698 ideas: usize,
1699 out: Option<PathBuf>,
1700 file_issues: bool,
1701 from: Option<&Path>,
1702) -> Result<i32> {
1703 if ideas == 0 {
1704 bail!("--ideas must be at least 1");
1705 }
1706 if let Some(path) = from {
1707 let cfg = config::load(common.config.as_deref())?;
1708 let repo = Repo::open(&common.repo, &cfg)?;
1709 let text = std::fs::read_to_string(path)
1710 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1711 let entries = brainstorm::parse_session(&text);
1712 if entries.is_empty() {
1713 bail!(
1714 "no ideas in {}: an idea is a `## ` heading with its sections under it",
1715 path.display()
1716 );
1717 }
1718 log!(
1719 "filing {} idea{} from {}",
1720 entries.len(),
1721 if entries.len() == 1 { "" } else { "s" },
1722 path.display()
1723 );
1724 brainstorm::file_ideas(&repo, &entries);
1725 return Ok(report_writes(&repo));
1726 }
1727
1728 let overrides = Overrides {
1729 max_rounds,
1730 ..Overrides::default()
1731 };
1732 let (cfg, repo, agents) = prepare(common, Some(overrides))?;
1733 let joined = topic.join(" ").trim().to_string();
1734 let session = brainstorm::Session {
1735 topic: (!joined.is_empty()).then_some(joined),
1736 budget: cfg.loop_cfg.max_rounds,
1737 wanted: ideas,
1738 };
1739 let now = brainstorm::now_secs();
1740 let outcome = brainstorm::run(&agents, &cfg, &repo, &session)?;
1741 let path = out.unwrap_or_else(|| brainstorm::default_path(&repo, &session, now));
1742 let text = brainstorm::render_session(&outcome, &brainstorm::date_line(now), &repo.style);
1743 crate::repo::write_text_atomic(&path, &text)?;
1744 brainstorm::print_summary(&outcome, &path);
1745 if file_issues {
1746 println!();
1747 brainstorm::file_ideas(&repo, &brainstorm::issue_entries(&outcome, &repo.style));
1748 }
1749
1750 let spent = spend::taken();
1751 if let Err(e) = repo.record_spend(&spent) {
1752 logdim!("could not record what this run spent: {e}");
1753 }
1754 if let Some(summary) = spend::summary(&spent) {
1755 println!("\n{summary}");
1756 }
1757 Ok(report_writes(&repo))
1758}
1759
1760fn cmd_init_update(out: &Path) -> Result<i32> {
1766 let text = std::fs::read_to_string(out)
1767 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1768 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1771
1772 let unset = config::unmentioned_options(&text);
1773 if unset.is_empty() {
1774 println!("{} already mentions every setting.", out.display());
1775 return Ok(0);
1776 }
1777
1778 let mut block = String::new();
1779 if !text.ends_with('\n') {
1780 block.push('\n');
1781 }
1782 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1783 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1784 let mut section = "";
1785 for option in &unset {
1786 if option.section != section {
1787 section = option.section;
1788 block.push_str(&format!("\n# [{section}]\n"));
1789 }
1790 block.push('\n');
1795 block.push_str(&wrap_comment(note_for(&option.key)));
1796 block.push_str(&format!("# {} = {}\n", option.key, option.default));
1797 }
1798
1799 use std::io::Write;
1800 std::fs::OpenOptions::new()
1801 .append(true)
1802 .open(out)
1803 .and_then(|mut f| f.write_all(block.as_bytes()))
1804 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1805
1806 println!(
1807 "added {} setting(s) to {} as comments",
1808 unset.len(),
1809 out.display()
1810 );
1811 Ok(0)
1812}
1813
1814fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1815 if out.exists() && !force {
1816 logging::error(format!(
1817 "{} already exists. `--update` appends any settings it does not mention, \
1818 `--force` overwrites it.",
1819 out.display()
1820 ));
1821 return Ok(1);
1822 }
1823
1824 let presets = config::available_presets();
1825 if presets.is_empty() {
1826 bail!("no presets available, which should be impossible in a released build");
1827 }
1828
1829 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1830 for name in &presets {
1831 let raw = config::load_preset(name)?;
1832 let mut spec: config::AgentSpec = match raw
1836 .as_table()
1837 .cloned()
1838 .ok_or_else(|| spar_err!("not a table"))
1839 .and_then(|t| {
1840 toml::Value::Table(t)
1841 .try_into()
1842 .map_err(|e| spar_err!("{e}"))
1843 }) {
1844 Ok(spec) => spec,
1845 Err(e) => {
1846 println!(" BROKEN {name:10} {}", e.first_line());
1847 continue;
1848 }
1849 };
1850 spec.name = name.clone();
1851 match Agent::new(spec.clone()).resolve_bin() {
1852 Ok(path) => {
1853 println!(" found {name:10} {}", path.display());
1854 found.push((name.clone(), path.to_path_buf(), spec));
1855 }
1856 Err(_) => println!(" missing {name}"),
1857 }
1858 }
1859
1860 if found.len() < 2 {
1861 logging::error(format!(
1862 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1863 presets as a reference.",
1864 found.len(),
1865 out.display()
1866 ));
1867 return Ok(1);
1868 }
1869
1870 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1872 if found.len() > 2 {
1873 log!(
1874 "{} agents available, picking {} and {}. Edit {} to change.",
1875 found.len(),
1876 chosen[0].0,
1877 chosen[1].0,
1878 out.display()
1879 );
1880 }
1881
1882 let mut text = String::from(
1883 "# Generated by `spar init`. Each agent inherits a command template from a\n\
1884 # built in preset; anything set here overrides it.\n\
1885 #\n\
1886 # Commented lines are the other options, each with a working value.\n\
1887 # Uncomment one to change it.\n\n",
1888 );
1889 for (name, _, spec) in &chosen {
1890 text.push_str(&agent_block(name, spec));
1891 }
1892 text.push_str(&settings_block(&chosen[0].0));
1893
1894 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1895 println!("\nwrote {}", out.display());
1896 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1897 Ok(0)
1898}
1899
1900fn report_fallback(agent: &Agent) {
1906 let Some(backup) = agent.fallback() else {
1907 return;
1908 };
1909 match backup.resolve_bin() {
1910 Ok(bin) => println!(
1911 " fallback {} ({})",
1912 bin.display(),
1913 backup.spec.describe()
1914 ),
1915 Err(_) => println!(
1916 " fallback {} not found, so it will not stand in. Set {} to its path.",
1917 backup.program(),
1918 backup.env_key()
1919 ),
1920 }
1921}
1922
1923type Setting = (bool, &'static str, &'static str);
1934
1935const LOOP_OPTIONS: &[Setting] = &[
1936 (false, "max_rounds", "In the custody loop, review rounds one invocation may spend asking for changes. A full-budget run may then use one extra closing pass. In `spar review`, this selects up to three phases: independent review, cross-adjudication, and rebuttal. Resuming a custody run grants a fresh budget, so this is not a lifetime cap."),
1937 (false, "auto_merge", "Merge when no blocking findings remain. Off on purpose: two models agreeing is not the same as being right, and neither carries the consequences."),
1938 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1939 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1940 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1941 (false, "followups", "Where a follow-up goes. issues files them, local writes .spar/followups.md and leaves the tracker alone, none drops them. `spar followup` works that file."),
1942 (true, "file_non_blocking", "File a non-blocking finding as a follow-up. Off, because not gating a merge is not the same as deserving somebody's triage queue."),
1943 (true, "max_followups", "Most follow-ups one run may record before it stops and says what it dropped. A backstop, not a target. `spar followup` is bounded by --limit instead."),
1944 (true, "max_split_parts", "Most parts `spar split` will make out of one issue or pull request. A backstop against a queue nobody asked for, not a target, and what it holds back is said out loud. A part is never itself split, so if one is still too big, `spar split <part>` is one command away."),
1945 (true, "check", "A command spar runs in the worktree after an editing call committed and before the branch is pushed, as an argv list or a string split on whitespace. Unset runs nothing. \"I ran the tests\" is the one claim in a report that nothing else checks, and a broken push costs the reviewer's round on a tree that does not build. On failure the output goes back to the same agent once; a second failure ends the round with the worktree kept and nothing pushed."),
1946 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1947 (true, "min_number", "Ignore issues and pull requests numbered below this when spar picks for itself. 0 is no floor, and a number you name explicitly is always honoured."),
1948 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1949 (true, "absorb_new_issues", "Waves of newly filed follow-ups to fold back into this run rather than leaving them for the next one. Multiplies what a run costs."),
1950 (true, "decompose_trackers", "Turn the checklist in a tracking issue into issues, link each item to the one covering it, tick an item off when its issue closes, and work the children in this run. Only ever acts on markdown task list items, so it is opt in per issue as well. `spar triage` prints what it would do without writing any of it."),
1951 (true, "max_tracker_children", "Most unchecked items from one tracker's checklist that one run will act on. A cap, not a target, and what it left is named out loud."),
1952 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1953 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1954 (true, "branch_prefix", "Namespace the branches spar creates, for example \"spar/\". Without it they are issue-N, pr-N, and split-N-I with a suffix on repeated split names."),
1955 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1956 (true, "drafts", "Whether a pull request starts as a draft. until_approved opens one and marks it ready when the review converges, which is what the draft was saying while two agents were still arguing about it. always opens one and leaves it, and cannot be combined with auto_merge."),
1957 (true, "instructions", "Extra instructions handed to both agents with every request, for what this repository always wants that spar has no setting for. --instructions adds to this for one run."),
1958 (true, "max_issue_chars", "Most of one issue body that reaches a prompt. Sized so nothing a person wrote is cut, and a cut is said out loud when it happens."),
1959 (true, "max_triage_chars", "Most every issue body together may add to one triage prompt, or every recorded follow-up in one screening prompt. Past it, whole items wait for the next run rather than all of them losing their tails."),
1960 (true, "checkin_trust", "Whose comments `spar checkin` will act on. write is anybody GitHub says can write to this repository, which is the default because acting on a comment means pushing a commit to somebody's branch. anyone answers everyone, and still only changes code when both agents agree."),
1961 (true, "checkin_resolve", "Mark a review thread resolved when spar made the change it asked for. A thread spar disagreed with is left open whatever this says."),
1962 (true, "max_checkin_comments", "Most unanswered comments spar will answer on one pull request in a run. A backstop against a long argument being read back to somebody, not a target."),
1963];
1964
1965const STYLE_OPTIONS: &[Setting] = &[
1966 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1967 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1968 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1969 (true, "pr_comments", "How much of its own working spar narrates into a pull request thread. outcome is one comment at the end, rounds is an audit trail, none never comments at all."),
1970 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1971 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1972 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1973 (true, "max_body_chars", "A pull request body."),
1974 (true, "max_issue_body_chars", "A filed issue's body. Far larger on purpose: an issue is picked up cold months later. Fenced code blocks are never truncated and never count against it."),
1975];
1976
1977fn settings_block(first_implementor: &str) -> String {
1982 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1983 .into_iter()
1984 .map(|option| (option.key, option.default))
1985 .collect();
1986 let value = |key: &str| match key {
1989 "first_implementor" => format!("\"{first_implementor}\""),
1990 other => defaults.get(other).cloned().unwrap_or_default(),
1991 };
1992
1993 let mut out = String::from("[loop]\n");
1994 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1995 out.push_str(concat!(
1996 "\n[loop.effort_schedule]\n",
1997 "# One key per kind of call, so a value means what its name says. Values\n",
1998 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
1999 "# examples rather than defaults. Left out, a call falls back to\n",
2000 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2001 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2002 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2003 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2004 "# implement = \"high\" # writing the first draft\n",
2005 "# review_1 = \"high\" # the deep first review\n",
2006 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2007 "# respond = \"high\" # the author answering a review\n",
2008 "# close = \"low\" # the closing merge safety pass\n",
2009 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2010 "# checkin = \"high\" # judging comments other people left\n",
2011 "# split = \"high\" # proposing and checking a split\n",
2012 "# brainstorm = \"high\" # every brainstorm round\n",
2013 "# round_1 = \"high\" # read by any call above with no key set\n",
2014 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2015 ));
2016 out.push_str("[style]\n");
2017 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2018 out
2019}
2020
2021fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2025 let mut out = String::new();
2026 for (commented, key, note) in options {
2027 if !out.is_empty() {
2028 out.push('\n');
2029 }
2030 out.push_str(&wrap_comment(note));
2031 let lead = if *commented { "# " } else { "" };
2032 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2033 }
2034 out
2035}
2036
2037type Probe = Box<dyn Fn() -> Result<String>>;
2040
2041fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2048 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2049
2050 if let Some(extra) = &spec.options_note {
2062 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2063 out.push('\n');
2064 out.push_str(&wrap_comment(extra));
2065 }
2066 }
2067 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2068 let Some(suggested) = choices.first() else {
2069 continue;
2070 };
2071 let mut note = format!("Omit {key} to use the CLI's own default.");
2072 if choices.len() > 1 {
2073 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2074 }
2075 out.push('\n');
2076 out.push_str(&wrap_comment(¬e));
2077 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2078 }
2079
2080 out.push('\n');
2084 out.push_str(&wrap_comment(
2085 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2086 never retried, so err long.",
2087 ));
2088 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2089
2090 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2093 out.push('\n');
2094 out.push_str(&wrap_comment(
2095 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2096 of this agent, never alongside it.",
2097 ));
2098 out.push_str(&format!(
2099 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2100 ));
2101
2102 out.push('\n');
2107 out.push_str(&wrap_comment(
2108 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2109 that has no preset.",
2110 ));
2111 out.push('\n');
2112 out
2113}
2114
2115fn note_for(key: &str) -> &'static str {
2121 LOOP_OPTIONS
2122 .iter()
2123 .chain(STYLE_OPTIONS)
2124 .find(|(_, name, _)| *name == key)
2125 .map(|(_, _, note)| *note)
2126 .unwrap_or("")
2127}
2128
2129fn wrap_comment(text: &str) -> String {
2131 const WIDTH: usize = 76;
2132 let mut out = String::new();
2133 let mut line = String::from("#");
2134 for word in text.split_whitespace() {
2135 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2136 out.push_str(&line);
2137 out.push('\n');
2138 line = String::from("#");
2139 }
2140 line.push(' ');
2141 line.push_str(word);
2142 }
2143 if line.len() > 1 {
2144 out.push_str(&line);
2145 out.push('\n');
2146 }
2147 out
2148}
2149
2150fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2151 let mut ok = true;
2152
2153 let probes: Vec<(&str, Probe)> = vec![
2154 (
2155 "git",
2156 Box::new(|| {
2157 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2158 .map(|s| first_line(&s))
2159 }),
2160 ),
2161 (
2162 "gh",
2163 Box::new(|| {
2164 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2165 .map(|s| first_line(&s))
2166 }),
2167 ),
2168 (
2169 "gh auth",
2170 Box::new(|| {
2171 let out = proc::exec(
2172 &["gh".into(), "auth".into(), "status".into()],
2173 &ExecOpts::new().check(false).timeout_secs(60),
2174 )?;
2175 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2176 if out.ok() {
2177 Ok(first_line(&text))
2178 } else {
2179 Err(spar_err!("not authenticated. Run `gh auth login`."))
2180 }
2181 }),
2182 ),
2183 ];
2184
2185 for (label, probe) in probes {
2186 match probe() {
2187 Ok(detail) => println!(" ok {label:12} {detail}"),
2188 Err(e) => {
2189 println!(" FAIL {label:12} {}", e.first_line());
2190 ok = false;
2191 }
2192 }
2193 }
2194
2195 let found = config::find_config(config_path)?;
2196 let Some(path) = found else {
2197 println!("\n no spar.toml found. Run `spar init` to generate one.");
2198 println!(
2199 " presets available: {}",
2200 config::available_presets().join(", ")
2201 );
2202 return Ok(if ok { 0 } else { 1 });
2203 };
2204
2205 println!("\n config: {}", path.display());
2206 let cfg = match config::load(Some(&path)) {
2207 Ok(cfg) => cfg,
2208 Err(e) => {
2209 println!(" FAIL config {e}");
2210 return Ok(1);
2211 }
2212 };
2213
2214 let mut resolved = Vec::new();
2217 for spec in &cfg.agents {
2218 let agent = Agent::new(spec.clone());
2219 match agent.resolve_bin() {
2220 Ok(bin) => {
2221 println!(
2222 " ok {:12} {} ({})",
2223 spec.name,
2224 bin.display(),
2225 spec.describe()
2226 );
2227 report_fallback(&agent);
2228 resolved.push(agent);
2229 }
2230 Err(e) => {
2231 println!(" FAIL {:12} {}", spec.name, e.first_line());
2232 ok = false;
2233 }
2234 }
2235 }
2236
2237 if resolved.len() == cfg.agents.len() {
2238 if let Some(warning) = agent::correlation_warning(&resolved) {
2239 println!("\n WARNING {warning}");
2240 }
2241 }
2242
2243 for line in unknown_effort_values(&cfg) {
2244 println!("\n WARNING {line}");
2245 }
2246
2247 println!(
2248 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2249 cfg.first_implementor_policy(),
2250 cfg.loop_cfg.max_rounds,
2251 cfg.loop_cfg.auto_merge,
2252 cfg.loop_cfg.worktrees,
2253 cfg.loop_cfg.followups,
2254 cfg.style.terse
2255 );
2256 if let Ok(text) = std::fs::read_to_string(&path) {
2260 let unset = config::unmentioned_options(&text);
2261 if !unset.is_empty() {
2262 println!(
2263 "\n {} setting(s) this config does not mention, all at their defaults:",
2264 unset.len()
2265 );
2266 for option in &unset {
2267 println!(
2268 " [{}] {} = {}",
2269 option.section, option.key, option.default
2270 );
2271 }
2272 println!(
2273 " `spar init --update {}` appends them as comments.",
2274 path.display()
2275 );
2276 }
2277 }
2278
2279 println!(
2280 "{}",
2281 if ok {
2282 "\nready"
2283 } else {
2284 "\nmissing prerequisites"
2285 }
2286 );
2287 Ok(if ok { 0 } else { 1 })
2288}
2289
2290fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2299 let mut out = Vec::new();
2300 for spec in &cfg.agents {
2301 if spec.efforts.is_empty() {
2302 continue;
2303 }
2304 let mut said: Vec<String> = Vec::new();
2305 for call in config::Call::every() {
2306 let Some(value) = cfg.effort_for(spec, call) else {
2307 continue;
2308 };
2309 let known = spec
2310 .efforts
2311 .iter()
2312 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2313 if known || said.contains(&value) {
2314 continue;
2315 }
2316 said.push(value.clone());
2317 out.push(format!(
2318 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2319 to accept ({}). Put this agent's values under \
2320 [agents.{}.effort_schedule].",
2321 spec.name,
2322 call.key(),
2323 spec.efforts.join(" | "),
2324 spec.name
2325 ));
2326 }
2327 }
2328 out
2329}
2330
2331fn first_line(text: &str) -> String {
2332 text.trim().lines().next().unwrap_or("").trim().to_string()
2333}
2334
2335fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2340 report_with(results, cfg, repo, &[], &[])
2341}
2342
2343fn report_with(
2350 results: &[IssueRun],
2351 cfg: &Config,
2352 repo: &Repo,
2353 stopped: &[String],
2354 parked: &[String],
2355) -> i32 {
2356 let spent = spend::taken();
2359 if let Err(e) = repo.record_spend(&spent) {
2360 logdim!("could not record what this run spent: {e}");
2361 }
2362 println!("\n{}", "=".repeat(60));
2363 for r in results {
2364 println!(
2365 "#{:<5} {:<10} rounds={} {}",
2366 r.issue,
2367 r.status.to_string(),
2368 r.rounds,
2369 r.pr.as_deref().unwrap_or("")
2370 );
2371 for note in &r.notes {
2372 println!(" {}", first_line(note));
2373 }
2374 for url in &r.filed {
2375 println!(" filed {url}");
2376 }
2377 for dispute in &r.disputes {
2378 println!(
2379 " disputed: {}",
2380 report_item(&dispute.title, &dispute.file)
2381 );
2382 }
2383 for finding in &r.noted {
2384 println!(
2385 " noted, not blocking: {}",
2386 report_item(&finding.title, &finding.file)
2387 );
2388 }
2389 if let Some(cost) = spend::for_subject(&spent, r.issue) {
2390 println!(" {cost}");
2391 }
2392 }
2393 println!("{}", "=".repeat(60));
2394
2395 if cfg.alternate_first && results.len() > 1 {
2396 println!(
2397 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2398 .spar/state/implementors.json.",
2399 cfg.first_implementor
2400 );
2401 }
2402 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2403 println!("\nApproved PRs are waiting on you to merge.");
2404 }
2405 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2406 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2407 println!(
2408 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2409 Set followups = \"issues\" to file them."
2410 );
2411 }
2412 if let Some(summary) = spend::summary(&spent) {
2413 println!("\n{summary}");
2414 }
2415 for lines in [parked, stopped] {
2416 if lines.is_empty() {
2417 continue;
2418 }
2419 println!();
2420 for line in lines {
2421 println!("{line}");
2422 }
2423 }
2424 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2425 issue_exit.max(report_writes(repo))
2426}
2427
2428fn report_writes(repo: &Repo) -> i32 {
2433 let writes = repo.write_summary();
2434 if let Some(line) = write_summary_line(writes) {
2435 println!("\n{line}");
2436 }
2437 write_exit_code(writes)
2438}
2439
2440fn write_summary_line(writes: WriteSummary) -> Option<String> {
2441 (writes.attempted > 0).then(|| {
2442 format!(
2443 "writes: {} attempted, {} succeeded, {} failed",
2444 writes.attempted,
2445 writes.succeeded(),
2446 writes.failed
2447 )
2448 })
2449}
2450
2451fn write_exit_code(writes: WriteSummary) -> i32 {
2452 i32::from(writes.failed > 0)
2453}
2454
2455fn report_item(title: &str, file: &str) -> String {
2456 match file.trim() {
2457 "" => title.to_string(),
2458 location => format!("{title} ({location})"),
2459 }
2460}
2461
2462#[cfg(test)]
2463mod tests {
2464 use super::*;
2465 use clap::CommandFactory;
2466
2467 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2468 PlanItem {
2469 issue,
2470 title: format!("issue {issue}"),
2471 complexity: crate::model::Complexity::S,
2472 risk: crate::model::Risk::Low,
2473 depends_on,
2474 reason: "worth doing".into(),
2475 }
2476 }
2477
2478 #[test]
2487 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2488 let plan = Plan {
2489 order: vec![planned(9, vec![]), planned(10, vec![9])],
2490 ..Plan::default()
2491 };
2492 let mut landed = BTreeSet::new();
2493
2494 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2495 assert!(why.contains("#9"), "{why}");
2496 assert!(why.contains("has not merged"), "{why}");
2497 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2498
2499 landed.insert(9);
2501 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2502 }
2503
2504 #[test]
2507 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2508 let declined = Plan {
2509 order: vec![planned(10, vec![9])],
2510 skipped: vec![crate::model::SkippedItem {
2511 issue: 9,
2512 title: "issue 9".into(),
2513 reasons: Default::default(),
2514 tracker: false,
2515 }],
2516 ..Plan::default()
2517 };
2518 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2519 assert!(why.contains("declined by both reviewers"), "{why}");
2520
2521 let contested = Plan {
2522 order: vec![planned(10, vec![9])],
2523 contested: vec![crate::model::ContestedItem {
2524 issue: 9,
2525 title: "issue 9".into(),
2526 positions: Default::default(),
2527 reasons: Default::default(),
2528 note: None,
2529 }],
2530 ..Plan::default()
2531 };
2532 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2533 assert!(why.contains("disagree about #9"), "{why}");
2534 }
2535
2536 #[test]
2540 fn a_dependency_outside_the_run_does_not_hold_anything() {
2541 let plan = Plan {
2542 order: vec![planned(10, vec![900])],
2543 ..Plan::default()
2544 };
2545 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2546 }
2547
2548 #[test]
2554 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2555 let text = "\
2556[agents.claude]
2557preset = \"claude\"
2558
2559[agents.codex]
2560preset = \"codex\"
2561
2562[loop.effort_schedule]
2563review_1 = \"ultra\"
2564";
2565 let cfg = config::parse(text).expect("parses");
2566 let said = unknown_effort_values(&cfg);
2567 assert_eq!(1, said.len(), "{said:?}");
2568 assert!(said[0].starts_with("claude"), "{}", said[0]);
2569 assert!(said[0].contains("ultra"), "{}", said[0]);
2570 assert!(said[0].contains("review_1"), "{}", said[0]);
2571 assert!(
2572 said[0].contains("[agents.claude.effort_schedule]"),
2573 "it has to say where to put the right value: {}",
2574 said[0]
2575 );
2576
2577 let quiet = text.replace(
2579 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2580 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2581 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2582 );
2583 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2584 }
2585
2586 #[test]
2587 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2588 let mut positions_map = std::collections::BTreeMap::new();
2589 positions_map.insert("claude".to_string(), "do".to_string());
2590 positions_map.insert("codex".to_string(), "skip".to_string());
2591 let mut reasons = std::collections::BTreeMap::new();
2592 reasons.insert(
2593 "claude".to_string(),
2594 "the retry path is still wrong".to_string(),
2595 );
2596 reasons.insert(
2597 "codex".to_string(),
2598 "fixed in the rewrite last month".to_string(),
2599 );
2600
2601 let line = positions(&positions_map, &reasons);
2602 assert!(line.contains("claude says do"), "{line}");
2603 assert!(line.contains("codex says skip"), "{line}");
2604 assert!(line.contains("the retry path is still wrong"), "{line}");
2605 assert!(line.contains("fixed in the rewrite"), "{line}");
2606
2607 let bare = positions(&positions_map, &Default::default());
2609 assert!(bare.contains("claude says do"), "{bare}");
2610 }
2611
2612 #[test]
2613 fn report_items_include_their_location() {
2614 assert_eq!(
2615 "Same title (src/a.rs:10)",
2616 report_item("Same title", "src/a.rs:10")
2617 );
2618 assert_eq!("General point", report_item("General point", ""));
2619 }
2620
2621 #[test]
2622 fn every_attempted_write_failing_is_a_failed_run() {
2623 let writes = WriteSummary {
2624 attempted: 3,
2625 failed: 3,
2626 };
2627
2628 assert_eq!(1, write_exit_code(writes));
2629 assert_eq!(
2630 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2631 write_summary_line(writes)
2632 );
2633 }
2634
2635 #[test]
2636 fn a_partial_write_failure_is_reported_after_the_run() {
2637 let writes = WriteSummary {
2638 attempted: 3,
2639 failed: 1,
2640 };
2641
2642 assert_eq!(1, write_exit_code(writes));
2643 assert_eq!(2, writes.succeeded());
2644 }
2645
2646 #[test]
2647 fn the_parser_is_internally_consistent() {
2648 Cli::command().debug_assert();
2649 }
2650
2651 #[test]
2652 fn quiet_is_accepted_before_or_after_the_subcommand() {
2653 for argv in [
2654 vec!["spar", "--quiet", "run", "42"],
2655 vec!["spar", "run", "42", "--quiet"],
2656 vec!["spar", "resume", "--quiet"],
2657 vec!["spar", "init", "-q"],
2658 ] {
2659 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2660 }
2661 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2662 }
2663
2664 #[test]
2665 fn several_issue_numbers_are_accepted() {
2666 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2667 match cli.command {
2668 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2669 other => panic!("{other:?}"),
2670 }
2671 }
2672
2673 #[test]
2674 fn issue_numbers_and_flags_can_be_interleaved() {
2675 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2676 match cli.command {
2677 Command::Run {
2678 issues, loop_flags, ..
2679 } => {
2680 assert_eq!(vec![42, 51], issues);
2681 assert!(loop_flags.auto_merge);
2682 }
2683 other => panic!("{other:?}"),
2684 }
2685 }
2686
2687 #[test]
2688 fn every_command_that_reads_a_config_accepts_one() {
2689 for argv in [
2690 vec!["spar", "run", "42"],
2691 vec!["spar", "triage"],
2692 vec!["spar", "resume"],
2693 vec!["spar", "followup"],
2694 vec!["spar", "checkin"],
2695 vec!["spar", "split"],
2696 vec!["spar", "brainstorm"],
2697 vec!["spar", "clean"],
2698 vec!["spar", "doctor"],
2699 ] {
2700 let mut full = argv.clone();
2701 full.extend(["--config", "other.toml"]);
2702 let cli = Cli::parse_from(&full);
2703 let config = match cli.command {
2704 Command::Run { common, .. }
2705 | Command::Triage { common, .. }
2706 | Command::Resume { common, .. }
2707 | Command::Followup { common, .. }
2708 | Command::Split { common, .. }
2709 | Command::Brainstorm { common, .. }
2710 | Command::Checkin { common, .. } => common.config,
2711 Command::Clean { config, .. } | Command::Doctor { config } => config,
2712 other => panic!("{other:?}"),
2713 };
2714 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2715 }
2716 }
2717
2718 #[test]
2719 fn auto_merge_is_off_unless_asked_for() {
2720 let cli = Cli::parse_from(["spar", "run"]);
2721 match cli.command {
2722 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2723 other => panic!("{other:?}"),
2724 }
2725 }
2726
2727 #[test]
2730 fn every_command_that_reads_a_config_takes_instructions() {
2731 for argv in [
2734 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2735 vec![
2736 "spar",
2737 "triage",
2738 "7",
2739 "--instructions",
2740 "Do not wait for CI.",
2741 ],
2742 vec![
2743 "spar",
2744 "resume",
2745 "7",
2746 "--instructions",
2747 "Do not wait for CI.",
2748 ],
2749 vec![
2750 "spar",
2751 "review",
2752 "7",
2753 "--instructions",
2754 "Do not wait for CI.",
2755 ],
2756 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2757 vec![
2758 "spar",
2759 "checkin",
2760 "7",
2761 "--instructions",
2762 "Do not wait for CI.",
2763 ],
2764 vec![
2765 "spar",
2766 "split",
2767 "7",
2768 "--instructions",
2769 "Do not wait for CI.",
2770 ],
2771 vec![
2772 "spar",
2773 "brainstorm",
2774 "a",
2775 "subject",
2776 "--instructions",
2777 "Do not wait for CI.",
2778 ],
2779 ] {
2780 let parsed = Cli::parse_from(&argv);
2781 let common = match parsed.command {
2782 Command::Run { common, .. }
2783 | Command::Triage { common, .. }
2784 | Command::Resume { common, .. }
2785 | Command::Review { common, .. }
2786 | Command::Followup { common, .. }
2787 | Command::Split { common, .. }
2788 | Command::Brainstorm { common, .. }
2789 | Command::Checkin { common, .. } => common,
2790 other => panic!("{other:?}"),
2791 };
2792 assert_eq!(
2793 Some("Do not wait for CI."),
2794 common.instructions.as_deref(),
2795 "{argv:?}"
2796 );
2797 }
2798 }
2799
2800 #[test]
2801 fn the_two_close_skipped_flags_are_mutually_exclusive() {
2802 assert!(
2803 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2804 );
2805 }
2806
2807 #[test]
2810 fn close_skipped_is_offered_only_where_it_means_something() {
2811 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2812 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2813 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2815 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2816 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2817 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2818 }
2819
2820 #[test]
2823 fn brainstorm_takes_words_or_nothing() {
2824 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
2825 Command::Brainstorm { topic, ideas, .. } => {
2826 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
2827 assert_eq!(5, ideas);
2828 }
2829 other => panic!("{other:?}"),
2830 }
2831 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
2832 Command::Brainstorm {
2833 topic,
2834 ideas,
2835 max_rounds,
2836 ..
2837 } => {
2838 assert!(topic.is_empty());
2839 assert_eq!(3, ideas);
2840 assert_eq!(Some(2), max_rounds);
2841 }
2842 other => panic!("{other:?}"),
2843 }
2844 }
2845
2846 #[test]
2850 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
2851 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
2852 assert!(
2853 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
2854 );
2855 assert!(Cli::try_parse_from([
2856 "spar",
2857 "brainstorm",
2858 "--from",
2859 "s.md",
2860 "--file-issues",
2861 "--out",
2862 "t.md"
2863 ])
2864 .is_err());
2865 assert!(
2866 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
2867 .is_ok()
2868 );
2869 }
2870
2871 #[test]
2874 fn followup_takes_no_numbers() {
2875 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
2876 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
2877 }
2878
2879 #[test]
2882 fn the_two_stopping_points_are_mutually_exclusive() {
2883 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
2884 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
2885 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
2886 }
2887
2888 #[test]
2889 fn the_close_skipped_pair_resolves_to_a_tristate() {
2890 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
2891 Command::Run { triage_flags, .. } => {
2892 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
2893 (true, _) => Some(true),
2894 (_, true) => Some(false),
2895 _ => None,
2896 }
2897 }
2898 other => panic!("{other:?}"),
2899 };
2900 assert_eq!(None, read(&["spar", "run"]));
2901 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
2902 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
2903 }
2904
2905 #[test]
2906 fn the_default_limit_is_twenty() {
2907 let cli = Cli::parse_from(["spar", "run"]);
2908 match cli.command {
2909 Command::Run { common, .. } => assert_eq!(20, common.limit),
2910 other => panic!("{other:?}"),
2911 }
2912 }
2913
2914 #[test]
2915 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
2916 assert!(matches!(
2917 Cli::parse_from(["spar", "scrub-filter"]).command,
2918 Command::ScrubFilter
2919 ));
2920 let help = Cli::command().render_long_help().to_string();
2921 assert!(
2922 !help.contains("scrub-filter"),
2923 "it is plumbing, not a command"
2924 );
2925 }
2926
2927 #[test]
2928 fn review_takes_pr_numbers_and_a_dry_run() {
2929 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
2930 match cli.command {
2931 Command::Review { items, dry_run, .. } => {
2932 assert_eq!(vec![101, 102], items);
2933 assert!(dry_run);
2934 }
2935 other => panic!("{other:?}"),
2936 }
2937 }
2938
2939 #[test]
2940 fn review_posts_unless_told_not_to() {
2941 match Cli::parse_from(["spar", "review", "101"]).command {
2942 Command::Review { dry_run, .. } => assert!(!dry_run),
2943 other => panic!("{other:?}"),
2944 }
2945 }
2946
2947 #[test]
2948 fn review_with_no_numbers_is_allowed() {
2949 match Cli::parse_from(["spar", "review"]).command {
2950 Command::Review { items, .. } => assert!(items.is_empty()),
2951 other => panic!("{other:?}"),
2952 }
2953 }
2954
2955 #[test]
2956 fn review_takes_its_own_round_budget() {
2957 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
2958 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
2959 other => panic!("{other:?}"),
2960 }
2961 }
2962
2963 #[test]
2964 fn checkin_takes_pr_numbers_and_a_dry_run() {
2965 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
2966 Command::Checkin { items, dry_run, .. } => {
2967 assert_eq!(vec![108, 112], items);
2968 assert!(dry_run);
2969 }
2970 other => panic!("{other:?}"),
2971 }
2972 match Cli::parse_from(["spar", "checkin"]).command {
2973 Command::Checkin { items, dry_run, .. } => {
2974 assert!(items.is_empty());
2975 assert!(!dry_run);
2976 }
2977 other => panic!("{other:?}"),
2978 }
2979 }
2980
2981 #[test]
2987 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
2988 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
2989 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
2990 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
2991 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
2992 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
2994 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
2995 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
2996 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
2997 }
2998
2999 #[test]
3000 fn resume_takes_a_next_override() {
3001 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3002 match cli.command {
3003 Command::Resume {
3004 prs, next_actor, ..
3005 } => {
3006 assert_eq!(vec![108], prs);
3007 assert_eq!(Some("codex".to_string()), next_actor);
3008 }
3009 other => panic!("{other:?}"),
3010 }
3011 }
3012}
3013
3014#[cfg(test)]
3015mod absorb_tests {
3016 use super::*;
3017
3018 #[test]
3019 fn absorb_is_off_unless_asked_for() {
3020 match Cli::parse_from(["spar", "run"]).command {
3021 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3022 other => panic!("{other:?}"),
3023 }
3024 }
3025
3026 #[test]
3027 fn absorb_takes_a_wave_count() {
3028 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3029 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3030 other => panic!("{other:?}"),
3031 }
3032 }
3033
3034 #[test]
3035 fn absorb_is_only_offered_where_issues_are_worked() {
3036 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3037 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3038 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3039 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3042 }
3043}
3044
3045#[cfg(test)]
3046mod split_tests {
3047 use super::*;
3048
3049 #[test]
3050 fn split_takes_numbers_of_either_kind() {
3051 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3052 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3053 other => panic!("{other:?}"),
3054 }
3055 }
3056
3057 #[test]
3060 fn split_with_no_numbers_is_allowed() {
3061 match Cli::parse_from(["spar", "split"]).command {
3062 Command::Split {
3063 items,
3064 dry_run,
3065 again,
3066 ..
3067 } => {
3068 assert!(items.is_empty());
3069 assert!(!dry_run);
3070 assert!(!again);
3071 }
3072 other => panic!("{other:?}"),
3073 }
3074 }
3075
3076 #[test]
3080 fn split_can_be_told_to_write_nothing() {
3081 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3082 Command::Split { dry_run, .. } => assert!(dry_run),
3083 other => panic!("{other:?}"),
3084 }
3085 }
3086
3087 #[test]
3090 fn split_can_be_told_to_split_something_it_already_split() {
3091 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3092 Command::Split { again, .. } => assert!(again),
3093 other => panic!("{other:?}"),
3094 }
3095 }
3096
3097 #[test]
3100 fn split_takes_the_flags_that_govern_picking() {
3101 match Cli::parse_from([
3102 "spar",
3103 "split",
3104 "--limit",
3105 "5",
3106 "--min-number",
3107 "480",
3108 "--config",
3109 "other.toml",
3110 ])
3111 .command
3112 {
3113 Command::Split { common, .. } => {
3114 assert_eq!(5, common.limit);
3115 assert_eq!(Some(480), common.min_number);
3116 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3117 }
3118 other => panic!("{other:?}"),
3119 }
3120 }
3121
3122 #[test]
3125 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3126 for flag in [
3127 vec!["--auto-merge"],
3128 vec!["--max-rounds", "2"],
3129 vec!["--close-skipped"],
3130 vec!["--no-close-skipped"],
3131 vec!["--keep-worktrees"],
3132 ] {
3133 let mut argv = vec!["spar", "split", "10"];
3134 argv.extend(flag.iter().copied());
3135 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3136 }
3137 }
3138}
3139
3140#[cfg(test)]
3141mod min_number_tests {
3142 use super::*;
3143
3144 fn read(argv: &[&str]) -> Option<i64> {
3145 match Cli::parse_from(argv).command {
3146 Command::Run { common, .. }
3147 | Command::Triage { common, .. }
3148 | Command::Resume { common, .. }
3149 | Command::Review { common, .. }
3150 | Command::Followup { common, .. }
3151 | Command::Split { common, .. }
3152 | Command::Checkin { common, .. } => common.min_number,
3153 other => panic!("{other:?}"),
3154 }
3155 }
3156
3157 #[test]
3158 fn there_is_no_floor_unless_one_is_asked_for() {
3159 assert_eq!(None, read(&["spar", "run"]));
3160 }
3161
3162 #[test]
3163 fn every_command_that_picks_for_itself_accepts_a_floor() {
3164 for cmd in ["run", "triage", "resume", "review", "checkin", "split"] {
3165 assert_eq!(
3166 Some(480),
3167 read(&["spar", cmd, "--min-number", "480"]),
3168 "{cmd}"
3169 );
3170 }
3171 }
3172}
3173
3174#[cfg(test)]
3175mod run_targets_tests {
3176 use super::*;
3177
3178 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3179 pairs.iter().copied().collect()
3180 }
3181
3182 #[test]
3187 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3188 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3189
3190 assert_eq!(vec![783, 784], kept);
3191 }
3192
3193 #[test]
3194 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3195 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3196
3197 assert_eq!(vec![783, 784, 771], kept);
3198 }
3199
3200 #[test]
3203 fn an_unreadable_pull_request_drops_no_issue() {
3204 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3205
3206 assert_eq!(vec![772, 783], kept);
3207 }
3208
3209 #[test]
3211 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3212 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3213
3214 assert_eq!(vec![783], kept);
3215 }
3216}
3217
3218#[cfg(test)]
3219mod settings_block_tests {
3220 use super::*;
3221
3222 fn written(line: &str) -> String {
3225 let after = line.split_once('=').expect("an assignment").1;
3226 let mut quoted = false;
3227 for (i, c) in after.char_indices() {
3228 match c {
3229 '"' => quoted = !quoted,
3230 '#' if !quoted => return after[..i].trim().to_string(),
3231 _ => {}
3232 }
3233 }
3234 after.trim().to_string()
3235 }
3236
3237 fn line_for(text: &str, key: &str) -> String {
3238 text.lines()
3239 .find(|l| {
3240 let bare = l.trim_start().trim_start_matches('#').trim_start();
3241 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3242 })
3243 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3244 .to_string()
3245 }
3246
3247 #[test]
3254 fn every_value_it_offers_is_the_default_it_actually_has() {
3255 let text = settings_block("claude");
3256 for option in config::known_options() {
3257 if option.section == "loop.effort_schedule" {
3261 continue;
3262 }
3263 let line = line_for(&text, &option.key);
3264 assert_eq!(
3265 option.default,
3266 written(&line),
3267 "the generated config offers `{}`, but the default is {}",
3268 line.trim(),
3269 option.default
3270 );
3271 }
3272 }
3273
3274 #[test]
3278 fn it_offers_every_option_the_parser_knows_about() {
3279 let text = settings_block("claude");
3280 let missing: Vec<String> = config::unmentioned_options(&text)
3281 .into_iter()
3282 .map(|o| format!("[{}] {}", o.section, o.key))
3283 .collect();
3284 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3285 }
3286
3287 #[test]
3291 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3292 let mut text = String::from(
3293 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3294 [agents.codex]\ncommand = [\"codex\"]\n\n",
3295 );
3296 for line in settings_block("claude").lines() {
3297 text.push_str(uncomment(line).unwrap_or(line));
3298 text.push('\n');
3299 }
3300 let cfg = config::parse(&text).expect("a config of its own suggestions");
3301 assert_eq!("claude", cfg.first_implementor);
3302 }
3303
3304 fn uncomment(line: &str) -> Option<&str> {
3307 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3308 let key = bare.split_once('=')?.0.trim();
3311 let named = !key.is_empty()
3312 && key
3313 .chars()
3314 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3315 named.then_some(bare)
3316 }
3317
3318 #[test]
3319 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3320 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3321 }
3322
3323 #[test]
3328 fn a_note_sits_above_the_option_it_describes() {
3329 let text = settings_block("claude");
3330 assert!(
3331 text.lines().all(|l| l.chars().count() <= 80),
3332 "a line runs off the edge:\n{text}"
3333 );
3334 assert!(
3335 text.lines().all(|l| !l.starts_with(' ')),
3336 "a line is indented, so the columns are back:\n{text}"
3337 );
3338
3339 let lines: Vec<&str> = text.lines().collect();
3341 let at = lines
3342 .iter()
3343 .position(|l| l.starts_with("max_rounds"))
3344 .expect("max_rounds");
3345 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3346 assert!(
3347 lines[at - 1].contains("lifetime cap"),
3348 "the note above it is the end of its own note: {:?}",
3349 lines[at - 1]
3350 );
3351 }
3352
3353 #[test]
3355 fn every_note_starts_as_a_sentence() {
3356 for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
3357 let first = note.chars().next().expect("a note");
3358 assert!(
3359 first.is_uppercase(),
3360 "{key} reads as a margin scribble rather than a sentence: {note}"
3361 );
3362 }
3363 }
3364}
3365
3366#[cfg(test)]
3367mod agent_block_tests {
3368 use super::*;
3369
3370 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3371 let mut spec: config::AgentSpec =
3372 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3373 .try_into()
3374 .expect("builds");
3375 spec.models = models.iter().map(|s| s.to_string()).collect();
3376 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3377 spec
3378 }
3379
3380 #[test]
3384 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3385 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3386 assert!(!block.contains("..."), "{block}");
3387 assert!(!block.contains("effort"), "{block}");
3388 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3389 }
3390
3391 #[test]
3394 fn only_the_options_that_follow_are_introduced() {
3395 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3396 assert!(model_only.contains("Omit model to use"), "{model_only}");
3397 assert!(!model_only.contains("Omit effort"), "{model_only}");
3398
3399 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3400 assert!(both.contains("Omit model to use"), "{both}");
3401 assert!(both.contains("Omit effort to use"), "{both}");
3402 }
3403
3404 #[test]
3407 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3408 let block = agent_block("gemini", &spec(&[], &[]));
3409 assert!(!block.contains("..."), "{block}");
3410 assert!(!block.contains("Omit"), "{block}");
3411 assert!(
3412 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3413 "{block}"
3414 );
3415 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3417 assert!(block.contains("# timeout = "), "{block}");
3418 }
3419
3420 #[test]
3423 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3424 let mut spec = spec(&["a"], &[]);
3425 spec.timeout = 7200;
3426 assert!(
3427 agent_block("custom", &spec).contains("# timeout = 7200"),
3428 "the generator kept its own copy"
3429 );
3430 }
3431
3432 #[test]
3434 fn alternatives_are_listed_only_when_there_are_any() {
3435 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3436 let single = agent_block("b", &spec(&["only"], &[]));
3437 assert!(!single.contains("One of:"), "{single}");
3438 }
3439
3440 #[test]
3444 fn the_presets_note_is_said_once() {
3445 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3446 spec.options_note = Some("Check the current sets with: mytool --help".into());
3447 let block = agent_block("mytool", &spec);
3448 assert_eq!(
3449 1,
3450 block.matches("Check the current sets").count(),
3451 "{block}"
3452 );
3453 }
3454}