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