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 let mut still_waiting: Vec<i64> = Vec::new();
965 wave.numbers.retain(|n| {
966 let Some(known) = waiting.get(n) else {
967 return true;
968 };
969 still_waiting.push(*n);
970 parked.push(format!(
971 "#{n} contested and parked for you: {}",
972 positions_of(known)
973 ));
974 false
975 });
976 if !still_waiting.is_empty() {
977 log!(
978 "{} contested on an earlier run and waiting on you. Pass --retriage to ask \
979 again.",
980 numbers(&still_waiting)
981 );
982 }
983 }
984 if wave.numbers.is_empty() {
985 continue;
986 }
987 match wave.parent {
988 Some(tracker) => log!(
989 "working {} item(s) from the checklist in #{tracker}: {}",
990 wave.numbers.len(),
991 numbers(&wave.numbers)
992 ),
993 None if wave.absorbed > 0 => log!(
994 "absorbing {} newly filed issue(s): {}",
995 wave.numbers.len(),
996 numbers(&wave.numbers)
997 ),
998 None => {}
999 }
1000 handled.extend(wave.numbers.iter().copied());
1001
1002 let fetched = match repo.fetch_issues(&wave.numbers) {
1003 Ok(fetched) => fetched,
1004 Err(e) => {
1005 logdim!("could not read the next wave: {e}");
1006 continue;
1007 }
1008 };
1009 let plan_path = if plans_written == 0 {
1012 plan_out.to_path_buf()
1013 } else {
1014 plan_out.with_extension(format!("wave{plans_written}.json"))
1015 };
1016 plans_written += 1;
1017 let plan = match make_plan(agents, cfg, repo, &fetched, &plan_path) {
1022 Ok(plan) => plan,
1023 Err(e) => {
1024 logwarn!("triage failed for {}: {e}", numbers(&wave.numbers));
1025 stopped.push(format!(
1026 "triage failed for {}, so it was not worked: {}",
1027 numbers(&wave.numbers),
1028 first_line(&e.to_string())
1029 ));
1030 for later in &queue {
1031 stopped.push(format!(
1032 "{} was not reached after that failure",
1033 numbers(&later.numbers)
1034 ));
1035 }
1036 queue.clear();
1037 break;
1038 }
1039 };
1040 act_on_plan(cfg, repo, &plan);
1041 repo.remember_contested(&plan.contested);
1042 for item in &plan.contested {
1043 parked.push(format!(
1044 "#{} contested and parked for you: {}",
1045 item.issue,
1046 positions(&item.positions, &item.reasons)
1047 ));
1048 }
1049 for item in &plan.skipped {
1050 let what = if item.tracker {
1051 "left open as a tracker"
1052 } else if cfg.loop_cfg.close_skipped {
1053 "commented on and closed"
1054 } else {
1055 "commented on and left open"
1056 };
1057 parked.push(format!(
1058 "#{} skipped by both reviewers, {what}: {}",
1059 item.issue,
1060 item.reasons
1061 .values()
1062 .next()
1063 .map(|r| first_line(r))
1064 .unwrap_or_default()
1065 ));
1066 }
1067
1068 if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
1072 for item in plan.skipped.iter().filter(|i| i.tracker) {
1073 let children = tracker::decompose(cfg, repo, item.issue);
1074 if !children.is_empty() {
1075 queue.push_back(wave.child(item.issue, children));
1076 }
1077 }
1078 }
1079
1080 let before = results.len();
1081 for item in &plan.order {
1082 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
1083 continue;
1084 };
1085 if let Some(why) = held_back(item, &plan, &landed) {
1086 log!("#{}: {why}", item.issue);
1087 let mut held = IssueRun::new(item.issue, item.title.clone());
1088 held.status = Status::Pending;
1089 held.notes.push(why);
1090 results.push(held);
1091 continue;
1092 }
1093 let run = review::run_nth_issue(agents, cfg, repo, item, issue, worked);
1094 worked += 1;
1095 if run.status == Status::Merged {
1096 landed.insert(item.issue);
1097 }
1098 results.push(run);
1099 }
1100
1101 let filed: Vec<i64> = results[before..]
1103 .iter()
1104 .flat_map(|r| r.filed.iter())
1105 .filter_map(|url| review::filed_issue_number(url))
1106 .filter(|n| !handled.contains(n))
1107 .collect::<BTreeSet<_>>()
1108 .into_iter()
1109 .collect();
1110 if filed.is_empty() {
1111 continue;
1112 }
1113 match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
1114 true => queue.push_back(wave.absorb(filed)),
1115 false => leftover.extend(filed),
1116 }
1117 }
1118 if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
1119 log!(
1120 "{} issue(s) filed in the last wave were left for a later run: {}",
1121 leftover.len(),
1122 numbers(&leftover.iter().copied().collect::<Vec<_>>())
1123 );
1124 }
1125 Ok(())
1126}
1127
1128fn positions(
1130 positions: &std::collections::BTreeMap<String, String>,
1131 reasons: &std::collections::BTreeMap<String, String>,
1132) -> String {
1133 if positions.is_empty() {
1134 return "no positions recorded".to_string();
1135 }
1136 positions
1137 .iter()
1138 .map(|(name, side)| match reasons.get(name) {
1139 Some(why) if !why.trim().is_empty() => {
1140 format!("{name} says {side} ({})", first_line(why))
1141 }
1142 _ => format!("{name} says {side}"),
1143 })
1144 .collect::<Vec<_>>()
1145 .join("; ")
1146}
1147
1148fn positions_of(known: &crate::model::Contested) -> String {
1149 positions(&known.positions, &known.reasons)
1150}
1151
1152fn held_back(item: &PlanItem, plan: &Plan, landed: &BTreeSet<i64>) -> Option<String> {
1164 for dep in &item.depends_on {
1165 if landed.contains(dep) {
1166 continue;
1167 }
1168 if plan.order.iter().any(|other| other.issue == *dep) {
1169 return Some(format!(
1170 "held back: #{dep} is in this run and has not merged, so its work is not on the \
1171 base this would be built from. Merge #{dep} and run #{} again.",
1172 item.issue
1173 ));
1174 }
1175 if plan.skipped.iter().any(|s| s.issue == *dep) {
1176 return Some(format!(
1177 "held back: #{dep}, which it depends on, was declined by both reviewers"
1178 ));
1179 }
1180 if plan.contested.iter().any(|c| c.issue == *dep) {
1181 return Some(format!(
1182 "held back: the reviewers disagree about #{dep}, which it depends on"
1183 ));
1184 }
1185 }
1186 None
1187}
1188
1189struct Wave {
1191 numbers: Vec<i64>,
1192 absorbed: u32,
1197 parent: Option<i64>,
1199}
1200
1201impl Wave {
1202 fn first(numbers: Vec<i64>) -> Self {
1203 Self {
1204 numbers,
1205 absorbed: 0,
1206 parent: None,
1207 }
1208 }
1209
1210 fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
1211 Self {
1212 numbers,
1213 absorbed: self.absorbed,
1214 parent: Some(tracker),
1215 }
1216 }
1217
1218 fn absorb(&self, numbers: Vec<i64>) -> Self {
1219 Self {
1220 numbers,
1221 absorbed: self.absorbed + 1,
1222 parent: None,
1223 }
1224 }
1225}
1226
1227fn numbers(items: &[i64]) -> String {
1228 items
1229 .iter()
1230 .map(|n| format!("#{n}"))
1231 .collect::<Vec<_>>()
1232 .join(", ")
1233}
1234
1235fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
1236 let mut cfg = config::load(common.config.as_deref())?;
1237
1238 if let Some(first) = &common.first {
1239 if !cfg.has_agent(first) {
1240 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
1241 }
1242 cfg.first_implementor = first.clone();
1243 }
1244 if let Some(base) = &common.base {
1245 cfg.loop_cfg.base_branch = base.clone();
1246 }
1247 if let Some(min) = common.min_number {
1248 cfg.loop_cfg.min_number = min;
1249 }
1250 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
1254 if !extra.is_empty() {
1255 let standing = cfg.loop_cfg.instructions.trim();
1256 cfg.loop_cfg.instructions = if standing.is_empty() {
1257 extra.to_string()
1258 } else {
1259 format!("{standing}\n{extra}")
1260 };
1261 }
1262 }
1263 if let Some(over) = overrides {
1264 if let Some(v) = over.max_rounds {
1265 if v == 0 {
1266 bail!("--max-rounds must be at least 1");
1267 }
1268 cfg.loop_cfg.max_rounds = v;
1269 }
1270 if let Some(v) = over.auto_merge {
1271 cfg.loop_cfg.auto_merge = v;
1272 }
1273 if let Some(v) = over.keep_worktrees {
1274 cfg.loop_cfg.keep_worktrees = v;
1275 }
1276 if let Some(v) = over.worktrees {
1277 cfg.loop_cfg.worktrees = v;
1278 }
1279 if let Some(v) = over.close_skipped {
1280 cfg.loop_cfg.close_skipped = v;
1281 }
1282 if let Some(v) = over.absorb {
1283 cfg.loop_cfg.absorb_new_issues = v;
1284 }
1285 }
1286
1287 let repo = Repo::open(&common.repo, &cfg)?;
1288 if common.base.is_none() {
1289 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
1290 }
1291
1292 let agents = agent::build(&cfg)?;
1293 if let Some(warning) = agent::correlation_warning(&agents) {
1294 logging::warn(warning);
1295 }
1296
1297 for stale in repo.prune_worktrees(false) {
1299 let what = if stale.starts_with("branch ") {
1300 stale
1301 } else {
1302 format!("worktree {stale}")
1303 };
1304 logdim!("cleaned up finished {what}");
1305 }
1306
1307 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
1308 log!(
1309 "agents: {}, first {}",
1310 agents
1311 .iter()
1312 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1313 .collect::<Vec<_>>()
1314 .join(", "),
1315 cfg.first_implementor_policy()
1316 );
1317 Ok((cfg, repo, agents))
1318}
1319
1320fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1321 if !given.is_empty() {
1322 if min_number > 0 {
1324 let below: Vec<String> = given
1325 .iter()
1326 .filter(|n| **n < min_number)
1327 .map(|n| format!("#{n}"))
1328 .collect();
1329 if !below.is_empty() {
1330 logdim!(
1331 "{} below the #{min_number} floor, taking them because you named them",
1332 below.join(", ")
1333 );
1334 }
1335 }
1336 return Ok(given);
1337 }
1338 let found = repo.list_open_issues(limit, min_number)?;
1339 if found.is_empty() {
1340 log!("no open issues");
1341 return Ok(found);
1342 }
1343 log!(
1344 "no issues given, taking {} open: {}",
1345 found.len(),
1346 found
1347 .iter()
1348 .map(|n| format!("#{n}"))
1349 .collect::<Vec<_>>()
1350 .join(", ")
1351 );
1352 Ok(found)
1353}
1354
1355fn pick_for_split(
1368 agents: &[Agent],
1369 cfg: &Config,
1370 repo: &Repo,
1371 limit: usize,
1372 mode: &split::Mode,
1373) -> Result<Vec<(i64, ItemKind)>> {
1374 let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1375 let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1376 log!(
1377 "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1378 issues.len(),
1379 prs.len()
1380 );
1381 if issues.is_empty() && prs.is_empty() {
1382 return Ok(Vec::new());
1383 }
1384
1385 let issue_rows = repo.open_issue_rows();
1386 let pr_rows = repo.open_pr_rows();
1387 let mut candidates: Vec<split::Candidate> = Vec::new();
1388 let mut already = 0usize;
1389
1390 for number in &issues {
1391 let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1392 logdim!("could not read #{number}, leaving it alone");
1393 continue;
1394 };
1395 if split::already_split(row.body_text()) && !mode.again {
1398 already += 1;
1399 continue;
1400 }
1401 candidates.push(split::Candidate::from_issue(row));
1402 }
1403 for number in &prs {
1404 match pr_rows.iter().find(|p| p.number == *number) {
1405 Some(row) => candidates.push(split::Candidate::from_pr(row)),
1406 None => logdim!("could not read PR #{number}, leaving it alone"),
1407 }
1408 }
1409 if already > 0 {
1410 log!("{already} issue(s) already split, skipped. --again reopens them.");
1411 }
1412 if candidates.is_empty() {
1413 return Ok(Vec::new());
1414 }
1415
1416 let agent = agent::find(agents, &cfg.first_implementor)?;
1417 log!(
1418 "screening {} item(s) with {}",
1419 candidates.len(),
1420 agent.name()
1421 );
1422 let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1423
1424 let mut picked = Vec::new();
1425 for candidate in &candidates {
1426 match verdicts.iter().find(|v| v.item == candidate.number) {
1427 Some(v) if v.split => {
1428 log!(" split #{}: {}", candidate.number, v.reason.trim());
1429 picked.push((candidate.number, candidate.kind));
1430 }
1431 Some(v) => logdim!(" leave #{} whole: {}", candidate.number, v.reason.trim()),
1434 None => logdim!(" no verdict for #{}, leaving it whole", candidate.number),
1435 }
1436 }
1437 if picked.is_empty() {
1438 log!("nothing worth splitting");
1439 }
1440 Ok(picked)
1441}
1442
1443#[derive(Debug, Default)]
1449struct Sorted {
1450 issues: Vec<i64>,
1451 prs: Vec<i64>,
1452}
1453
1454fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1455 let mut sorted = Sorted::default();
1456 for number in numbers {
1457 match repo.item_kind(*number)? {
1458 ItemKind::Issue => sorted.issues.push(*number),
1459 ItemKind::Pr => sorted.prs.push(*number),
1460 }
1461 }
1462 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1463 log!(
1464 "{} issue(s) and {} pull request(s) given",
1465 sorted.issues.len(),
1466 sorted.prs.len()
1467 );
1468 }
1469 Ok(sorted)
1470}
1471
1472fn issues_covered_by(repo: &Repo, prs: &[i64]) -> BTreeMap<i64, i64> {
1485 let mut covered = BTreeMap::new();
1486 for number in prs {
1487 match repo.pr_view(*number) {
1488 Ok(view) => {
1489 for issue in &view.closing_issues_references {
1490 covered.insert(issue.number, *number);
1491 }
1492 }
1493 Err(e) => logwarn!(
1494 "#{number}: could not read which issue(s) it closes, so none are skipped: {}",
1495 e.last_line()
1496 ),
1497 }
1498 }
1499 covered
1500}
1501
1502fn issues_not_on_named_prs(issues: &[i64], covered: &BTreeMap<i64, i64>) -> Vec<i64> {
1505 issues
1506 .iter()
1507 .copied()
1508 .filter(|issue| !covered.contains_key(issue))
1509 .collect()
1510}
1511
1512fn make_plan(
1513 agents: &[Agent],
1514 cfg: &Config,
1515 repo: &Repo,
1516 issues: &[Issue],
1517 plan_out: &Path,
1518) -> Result<Plan> {
1519 let plan = triage::triage(agents, cfg, repo, issues)?;
1520
1521 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1522 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1523 log!("plan written to {}", plan_out.display());
1524
1525 for item in &plan.order {
1526 let after = if item.depends_on.is_empty() {
1529 String::new()
1530 } else {
1531 format!(
1532 " after {}",
1533 item.depends_on
1534 .iter()
1535 .map(|n| format!("#{n}"))
1536 .collect::<Vec<_>>()
1537 .join(", ")
1538 )
1539 };
1540 log!(
1541 " do #{} [{}/{}]{after} {}",
1542 item.issue,
1543 item.complexity,
1544 item.risk,
1545 item.title
1546 );
1547 }
1548 for item in &plan.skipped {
1549 if item.tracker {
1550 log!(
1551 " hold #{} (both reviewers: tracks work filed elsewhere)",
1552 item.issue
1553 );
1554 } else {
1555 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
1556 }
1557 }
1558 for item in &plan.contested {
1559 log!(" ?? #{} contested, parked for you to decide", item.issue);
1560 }
1561 Ok(plan)
1562}
1563
1564fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1574 for item in &plan.skipped {
1575 let body = review::skip_comment(item, &repo.style);
1576 let close = cfg.loop_cfg.close_skipped && !item.tracker;
1577 let outcome = if close {
1578 repo.close_issue(item.issue, &body)
1579 } else {
1580 repo.comment_issue(item.issue, &body)
1581 };
1582 match outcome {
1583 Ok(()) if close => log!(" closed #{}", item.issue),
1584 Ok(()) if item.tracker => {
1585 log!(
1586 " left #{} open, it tracks work filed elsewhere",
1587 item.issue
1588 )
1589 }
1590 Ok(()) => {}
1591 Err(e) => logdim!("could not update #{}: {e}", item.issue),
1592 }
1593 }
1594}
1595
1596fn cmd_scrub_filter() -> Result<i32> {
1601 let mut input = String::new();
1602 std::io::stdin()
1603 .read_to_string(&mut input)
1604 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1605 let out = style::scrub(&input, &crate::repo::style_from_env());
1606 let mut stdout = std::io::stdout();
1607 stdout
1608 .write_all(out.as_bytes())
1609 .and_then(|_| stdout.write_all(b"\n"))
1610 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1611 Ok(0)
1612}
1613
1614fn cmd_clean(
1615 repo_path: &Path,
1616 config_path: Option<&Path>,
1617 all: bool,
1618 pr_state: bool,
1619) -> Result<i32> {
1620 let cfg = config::load(config_path)?;
1621 let repo = Repo::open(repo_path, &cfg)?;
1622 let mut removed = repo.prune_worktrees(all);
1623 removed.extend(repo.prune_state());
1624 if pr_state {
1625 removed.extend(repo.prune_pr_state(None));
1626 }
1627 if removed.is_empty() {
1628 if repo.write_summary().failed > 0 {
1629 println!("nothing removed");
1630 } else {
1631 println!("nothing to clean");
1632 }
1633 } else {
1634 for item in removed {
1635 println!("removed {item}");
1636 }
1637 }
1638 Ok(report_writes(&repo))
1639}
1640
1641fn cmd_post(
1643 prs: &[i64],
1644 repo_path: &Path,
1645 config_path: Option<&Path>,
1646 file: Option<&Path>,
1647 dry_run: bool,
1648) -> Result<i32> {
1649 let cfg = config::load(config_path)?;
1650 let repo = Repo::open(repo_path, &cfg)?;
1651
1652 if file.is_some() && prs.len() > 1 {
1653 bail!("--file posts one review, so give it one pull request number");
1654 }
1655
1656 let mut failed = false;
1657 for number in prs {
1658 let text = match file {
1659 Some(path) => std::fs::read_to_string(path)
1660 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1661 None => match repo.read_pending_comment(*number) {
1662 Some(text) => text,
1663 None => {
1664 logging::error(format!(
1665 "no saved review for PR #{number}. `spar review {number} --dry-run` \
1666 produces one, or pass --file."
1667 ));
1668 failed = true;
1669 continue;
1670 }
1671 },
1672 };
1673 if text.trim().is_empty() {
1674 logging::error(format!("the saved review for PR #{number} is empty"));
1675 failed = true;
1676 continue;
1677 }
1678 if dry_run {
1679 println!("\n{}\n", text.trim());
1680 log!("would post the above to PR #{number}");
1681 continue;
1682 }
1683 match repo.comment_pr(*number, &text) {
1686 Ok(()) => log!("posted to PR #{number}"),
1687 Err(e) => {
1688 logging::error(format!("could not post to PR #{number}: {e}"));
1689 failed = true;
1690 }
1691 }
1692 }
1693 Ok(i32::from(failed).max(report_writes(&repo)))
1694}
1695
1696#[allow(clippy::too_many_arguments)]
1702fn cmd_brainstorm(
1703 topic: Vec<String>,
1704 common: &Common,
1705 max_rounds: Option<u32>,
1706 ideas: usize,
1707 out: Option<PathBuf>,
1708 file_issues: bool,
1709 from: Option<&Path>,
1710) -> Result<i32> {
1711 if ideas == 0 {
1712 bail!("--ideas must be at least 1");
1713 }
1714 if let Some(path) = from {
1715 let cfg = config::load(common.config.as_deref())?;
1716 let repo = Repo::open(&common.repo, &cfg)?;
1717 let text = std::fs::read_to_string(path)
1718 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?;
1719 let entries = brainstorm::parse_session(&text);
1720 if entries.is_empty() {
1721 bail!(
1722 "no ideas in {}: an idea is a `## ` heading with its sections under it",
1723 path.display()
1724 );
1725 }
1726 log!(
1727 "filing {} idea{} from {}",
1728 entries.len(),
1729 if entries.len() == 1 { "" } else { "s" },
1730 path.display()
1731 );
1732 brainstorm::file_ideas(&repo, &entries);
1733 return Ok(report_writes(&repo));
1734 }
1735
1736 let overrides = Overrides {
1737 max_rounds,
1738 ..Overrides::default()
1739 };
1740 let (cfg, repo, agents) = prepare(common, Some(overrides))?;
1741 let joined = topic.join(" ").trim().to_string();
1742 let session = brainstorm::Session {
1743 topic: (!joined.is_empty()).then_some(joined),
1744 budget: cfg.loop_cfg.max_rounds,
1745 wanted: ideas,
1746 };
1747 let now = brainstorm::now_secs();
1748 let outcome = brainstorm::run(&agents, &cfg, &repo, &session)?;
1749 let path = out.unwrap_or_else(|| brainstorm::default_path(&repo, &session, now));
1750 let text = brainstorm::render_session(&outcome, &brainstorm::date_line(now), &repo.style);
1751 crate::repo::write_text_atomic(&path, &text)?;
1752 brainstorm::print_summary(&outcome, &path);
1753 if file_issues {
1754 println!();
1755 brainstorm::file_ideas(&repo, &brainstorm::issue_entries(&outcome, &repo.style));
1756 }
1757
1758 let spent = spend::taken();
1759 if let Err(e) = repo.record_spend(&spent) {
1760 logdim!("could not record what this run spent: {e}");
1761 }
1762 if let Some(summary) = spend::summary(&spent) {
1763 println!("\n{summary}");
1764 }
1765 Ok(report_writes(&repo))
1766}
1767
1768fn cmd_init_update(out: &Path) -> Result<i32> {
1774 let text = std::fs::read_to_string(out)
1775 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1776 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1779
1780 let unset = config::unmentioned_options(&text);
1781 if unset.is_empty() {
1782 println!("{} already mentions every setting.", out.display());
1783 return Ok(0);
1784 }
1785
1786 let mut block = String::new();
1787 if !text.ends_with('\n') {
1788 block.push('\n');
1789 }
1790 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1791 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1792 let mut section = "";
1793 for option in &unset {
1794 if option.section != section {
1795 section = option.section;
1796 block.push_str(&format!("\n# [{section}]\n"));
1797 }
1798 block.push('\n');
1803 block.push_str(&wrap_comment(note_for(&option.key)));
1804 block.push_str(&format!("# {} = {}\n", option.key, option.default));
1805 }
1806
1807 use std::io::Write;
1808 std::fs::OpenOptions::new()
1809 .append(true)
1810 .open(out)
1811 .and_then(|mut f| f.write_all(block.as_bytes()))
1812 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1813
1814 println!(
1815 "added {} setting(s) to {} as comments",
1816 unset.len(),
1817 out.display()
1818 );
1819 Ok(0)
1820}
1821
1822fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1823 if out.exists() && !force {
1824 logging::error(format!(
1825 "{} already exists. `--update` appends any settings it does not mention, \
1826 `--force` overwrites it.",
1827 out.display()
1828 ));
1829 return Ok(1);
1830 }
1831
1832 let presets = config::available_presets();
1833 if presets.is_empty() {
1834 bail!("no presets available, which should be impossible in a released build");
1835 }
1836
1837 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1838 for name in &presets {
1839 let raw = config::load_preset(name)?;
1840 let mut spec: config::AgentSpec = match raw
1844 .as_table()
1845 .cloned()
1846 .ok_or_else(|| spar_err!("not a table"))
1847 .and_then(|t| {
1848 toml::Value::Table(t)
1849 .try_into()
1850 .map_err(|e| spar_err!("{e}"))
1851 }) {
1852 Ok(spec) => spec,
1853 Err(e) => {
1854 println!(" BROKEN {name:10} {}", e.first_line());
1855 continue;
1856 }
1857 };
1858 spec.name = name.clone();
1859 match Agent::new(spec.clone()).resolve_bin() {
1860 Ok(path) => {
1861 println!(" found {name:10} {}", path.display());
1862 found.push((name.clone(), path.to_path_buf(), spec));
1863 }
1864 Err(_) => println!(" missing {name}"),
1865 }
1866 }
1867
1868 if found.len() < 2 {
1869 logging::error(format!(
1870 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1871 presets as a reference.",
1872 found.len(),
1873 out.display()
1874 ));
1875 return Ok(1);
1876 }
1877
1878 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1880 if found.len() > 2 {
1881 log!(
1882 "{} agents available, picking {} and {}. Edit {} to change.",
1883 found.len(),
1884 chosen[0].0,
1885 chosen[1].0,
1886 out.display()
1887 );
1888 }
1889
1890 let mut text = String::from(
1891 "# Generated by `spar init`. Each agent inherits a command template from a\n\
1892 # built in preset; anything set here overrides it.\n\
1893 #\n\
1894 # Commented lines are the other options, each with a working value.\n\
1895 # Uncomment one to change it.\n\n",
1896 );
1897 for (name, _, spec) in &chosen {
1898 text.push_str(&agent_block(name, spec));
1899 }
1900 text.push_str(&settings_block(&chosen[0].0));
1901
1902 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1903 println!("\nwrote {}", out.display());
1904 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1905 Ok(0)
1906}
1907
1908fn report_fallback(agent: &Agent) {
1914 let Some(backup) = agent.fallback() else {
1915 return;
1916 };
1917 match backup.resolve_bin() {
1918 Ok(bin) => println!(
1919 " fallback {} ({})",
1920 bin.display(),
1921 backup.spec.describe()
1922 ),
1923 Err(_) => println!(
1924 " fallback {} not found, so it will not stand in. Set {} to its path.",
1925 backup.program(),
1926 backup.env_key()
1927 ),
1928 }
1929}
1930
1931type Setting = (bool, &'static str, &'static str);
1942
1943const LOOP_OPTIONS: &[Setting] = &[
1944 (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."),
1945 (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."),
1946 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1947 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1948 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1949 (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."),
1950 (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."),
1951 (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."),
1952 (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."),
1953 (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."),
1954 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1955 (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."),
1956 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1957 (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."),
1958 (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."),
1959 (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."),
1960 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1961 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1962 (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."),
1963 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1964 (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."),
1965 (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."),
1966 (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."),
1967 (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."),
1968 (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."),
1969 (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."),
1970 (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."),
1971];
1972
1973const STYLE_OPTIONS: &[Setting] = &[
1974 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1975 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1976 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1977 (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."),
1978 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1979 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1980 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1981 (true, "max_body_chars", "A pull request body."),
1982 (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."),
1983];
1984
1985fn settings_block(first_implementor: &str) -> String {
1990 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1991 .into_iter()
1992 .map(|option| (option.key, option.default))
1993 .collect();
1994 let value = |key: &str| match key {
1997 "first_implementor" => format!("\"{first_implementor}\""),
1998 other => defaults.get(other).cloned().unwrap_or_default(),
1999 };
2000
2001 let mut out = String::from("[loop]\n");
2002 out.push_str(&option_lines(LOOP_OPTIONS, &value));
2003 out.push_str(concat!(
2004 "\n[loop.effort_schedule]\n",
2005 "# One key per kind of call, so a value means what its name says. Values\n",
2006 "# are whatever each agent's own CLI accepts, listed above, so these are\n",
2007 "# examples rather than defaults. Left out, a call falls back to\n",
2008 "# round_1 or rest, and then to the effort the agent's own block asked\n",
2009 "# for. Where the two CLIs do not share a vocabulary, put the schedule\n",
2010 "# under [agents.NAME.effort_schedule] instead, with the same keys.\n",
2011 "# triage = \"low\" # both agents, over the whole queue, per wave\n",
2012 "# implement = \"high\" # writing the first draft\n",
2013 "# review_1 = \"high\" # the deep first review\n",
2014 "# review_rest = \"low\" # later rounds, which see a small delta\n",
2015 "# respond = \"high\" # the author answering a review\n",
2016 "# close = \"low\" # the closing merge safety pass\n",
2017 "# screen = \"low\" # the follow-up screen, one call for the queue\n",
2018 "# checkin = \"high\" # judging comments other people left\n",
2019 "# split = \"high\" # proposing and checking a split\n",
2020 "# brainstorm = \"high\" # every brainstorm round\n",
2021 "# round_1 = \"high\" # read by any call above with no key set\n",
2022 "# rest = \"low\" # the same, for later rounds and the close\n\n",
2023 ));
2024 out.push_str("[style]\n");
2025 out.push_str(&option_lines(STYLE_OPTIONS, &value));
2026 out
2027}
2028
2029fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
2033 let mut out = String::new();
2034 for (commented, key, note) in options {
2035 if !out.is_empty() {
2036 out.push('\n');
2037 }
2038 out.push_str(&wrap_comment(note));
2039 let lead = if *commented { "# " } else { "" };
2040 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
2041 }
2042 out
2043}
2044
2045type Probe = Box<dyn Fn() -> Result<String>>;
2048
2049fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
2056 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
2057
2058 if let Some(extra) = &spec.options_note {
2070 if !spec.models.is_empty() || !spec.efforts.is_empty() {
2071 out.push('\n');
2072 out.push_str(&wrap_comment(extra));
2073 }
2074 }
2075 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
2076 let Some(suggested) = choices.first() else {
2077 continue;
2078 };
2079 let mut note = format!("Omit {key} to use the CLI's own default.");
2080 if choices.len() > 1 {
2081 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
2082 }
2083 out.push('\n');
2084 out.push_str(&wrap_comment(¬e));
2085 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
2086 }
2087
2088 out.push('\n');
2092 out.push_str(&wrap_comment(
2093 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
2094 never retried, so err long.",
2095 ));
2096 out.push_str(&format!("# timeout = {}\n", spec.timeout));
2097
2098 let backup = if name == "cursor" { "gemini" } else { "cursor" };
2101 out.push('\n');
2102 out.push_str(&wrap_comment(
2103 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
2104 of this agent, never alongside it.",
2105 ));
2106 out.push_str(&format!(
2107 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
2108 ));
2109
2110 out.push('\n');
2115 out.push_str(&wrap_comment(
2116 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
2117 that has no preset.",
2118 ));
2119 out.push('\n');
2120 out
2121}
2122
2123fn note_for(key: &str) -> &'static str {
2129 LOOP_OPTIONS
2130 .iter()
2131 .chain(STYLE_OPTIONS)
2132 .find(|(_, name, _)| *name == key)
2133 .map(|(_, _, note)| *note)
2134 .unwrap_or("")
2135}
2136
2137fn wrap_comment(text: &str) -> String {
2139 const WIDTH: usize = 76;
2140 let mut out = String::new();
2141 let mut line = String::from("#");
2142 for word in text.split_whitespace() {
2143 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
2144 out.push_str(&line);
2145 out.push('\n');
2146 line = String::from("#");
2147 }
2148 line.push(' ');
2149 line.push_str(word);
2150 }
2151 if line.len() > 1 {
2152 out.push_str(&line);
2153 out.push('\n');
2154 }
2155 out
2156}
2157
2158fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
2159 let mut ok = true;
2160
2161 let probes: Vec<(&str, Probe)> = vec![
2162 (
2163 "git",
2164 Box::new(|| {
2165 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
2166 .map(|s| first_line(&s))
2167 }),
2168 ),
2169 (
2170 "gh",
2171 Box::new(|| {
2172 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
2173 .map(|s| first_line(&s))
2174 }),
2175 ),
2176 (
2177 "gh auth",
2178 Box::new(|| {
2179 let out = proc::exec(
2180 &["gh".into(), "auth".into(), "status".into()],
2181 &ExecOpts::new().check(false).timeout_secs(60),
2182 )?;
2183 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
2184 if out.ok() {
2185 Ok(first_line(&text))
2186 } else {
2187 Err(spar_err!("not authenticated. Run `gh auth login`."))
2188 }
2189 }),
2190 ),
2191 ];
2192
2193 for (label, probe) in probes {
2194 match probe() {
2195 Ok(detail) => println!(" ok {label:12} {detail}"),
2196 Err(e) => {
2197 println!(" FAIL {label:12} {}", e.first_line());
2198 ok = false;
2199 }
2200 }
2201 }
2202
2203 let found = config::find_config(config_path)?;
2204 let Some(path) = found else {
2205 println!("\n no spar.toml found. Run `spar init` to generate one.");
2206 println!(
2207 " presets available: {}",
2208 config::available_presets().join(", ")
2209 );
2210 return Ok(if ok { 0 } else { 1 });
2211 };
2212
2213 println!("\n config: {}", path.display());
2214 let cfg = match config::load(Some(&path)) {
2215 Ok(cfg) => cfg,
2216 Err(e) => {
2217 println!(" FAIL config {e}");
2218 return Ok(1);
2219 }
2220 };
2221
2222 let mut resolved = Vec::new();
2225 for spec in &cfg.agents {
2226 let agent = Agent::new(spec.clone());
2227 match agent.resolve_bin() {
2228 Ok(bin) => {
2229 println!(
2230 " ok {:12} {} ({})",
2231 spec.name,
2232 bin.display(),
2233 spec.describe()
2234 );
2235 report_fallback(&agent);
2236 resolved.push(agent);
2237 }
2238 Err(e) => {
2239 println!(" FAIL {:12} {}", spec.name, e.first_line());
2240 ok = false;
2241 }
2242 }
2243 }
2244
2245 if resolved.len() == cfg.agents.len() {
2246 if let Some(warning) = agent::correlation_warning(&resolved) {
2247 println!("\n WARNING {warning}");
2248 }
2249 }
2250
2251 for line in unknown_effort_values(&cfg) {
2252 println!("\n WARNING {line}");
2253 }
2254
2255 println!(
2256 "\n settings: first={} max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
2257 cfg.first_implementor_policy(),
2258 cfg.loop_cfg.max_rounds,
2259 cfg.loop_cfg.auto_merge,
2260 cfg.loop_cfg.worktrees,
2261 cfg.loop_cfg.followups,
2262 cfg.style.terse
2263 );
2264 if let Ok(text) = std::fs::read_to_string(&path) {
2268 let unset = config::unmentioned_options(&text);
2269 if !unset.is_empty() {
2270 println!(
2271 "\n {} setting(s) this config does not mention, all at their defaults:",
2272 unset.len()
2273 );
2274 for option in &unset {
2275 println!(
2276 " [{}] {} = {}",
2277 option.section, option.key, option.default
2278 );
2279 }
2280 println!(
2281 " `spar init --update {}` appends them as comments.",
2282 path.display()
2283 );
2284 }
2285 }
2286
2287 println!(
2288 "{}",
2289 if ok {
2290 "\nready"
2291 } else {
2292 "\nmissing prerequisites"
2293 }
2294 );
2295 Ok(if ok { 0 } else { 1 })
2296}
2297
2298fn unknown_effort_values(cfg: &config::Config) -> Vec<String> {
2307 let mut out = Vec::new();
2308 for spec in &cfg.agents {
2309 if spec.efforts.is_empty() {
2310 continue;
2311 }
2312 let mut said: Vec<String> = Vec::new();
2313 for call in config::Call::every() {
2314 let Some(value) = cfg.effort_for(spec, call) else {
2315 continue;
2316 };
2317 let known = spec
2318 .efforts
2319 .iter()
2320 .any(|hint| hint.eq_ignore_ascii_case(value.trim()));
2321 if known || said.contains(&value) {
2322 continue;
2323 }
2324 said.push(value.clone());
2325 out.push(format!(
2326 "{} is asked for effort \"{value}\" on the {} call, which is not one it is known \
2327 to accept ({}). Put this agent's values under \
2328 [agents.{}.effort_schedule].",
2329 spec.name,
2330 call.key(),
2331 spec.efforts.join(" | "),
2332 spec.name
2333 ));
2334 }
2335 }
2336 out
2337}
2338
2339fn first_line(text: &str) -> String {
2340 text.trim().lines().next().unwrap_or("").trim().to_string()
2341}
2342
2343fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
2348 report_with(results, cfg, repo, &[], &[])
2349}
2350
2351fn report_with(
2358 results: &[IssueRun],
2359 cfg: &Config,
2360 repo: &Repo,
2361 stopped: &[String],
2362 parked: &[String],
2363) -> i32 {
2364 let spent = spend::taken();
2367 if let Err(e) = repo.record_spend(&spent) {
2368 logdim!("could not record what this run spent: {e}");
2369 }
2370 println!("\n{}", "=".repeat(60));
2371 for r in results {
2372 println!(
2373 "#{:<5} {:<10} rounds={} {}",
2374 r.issue,
2375 r.status.to_string(),
2376 r.rounds,
2377 r.pr.as_deref().unwrap_or("")
2378 );
2379 for note in &r.notes {
2380 println!(" {}", first_line(note));
2381 }
2382 for url in &r.filed {
2383 println!(" filed {url}");
2384 }
2385 for dispute in &r.disputes {
2386 println!(
2387 " disputed: {}",
2388 report_item(&dispute.title, &dispute.file)
2389 );
2390 }
2391 for finding in &r.noted {
2392 println!(
2393 " noted, not blocking: {}",
2394 report_item(&finding.title, &finding.file)
2395 );
2396 }
2397 if let Some(cost) = spend::for_subject(&spent, r.issue) {
2398 println!(" {cost}");
2399 }
2400 }
2401 println!("{}", "=".repeat(60));
2402
2403 if cfg.alternate_first && results.len() > 1 {
2404 println!(
2405 "\nfirst implementor alternated, starting with {}. Who wrote each first draft is in \
2406 .spar/state/implementors.json.",
2407 cfg.first_implementor
2408 );
2409 }
2410 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
2411 println!("\nApproved PRs are waiting on you to merge.");
2412 }
2413 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
2414 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
2415 println!(
2416 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
2417 Set followups = \"issues\" to file them."
2418 );
2419 }
2420 if let Some(summary) = spend::summary(&spent) {
2421 println!("\n{summary}");
2422 }
2423 for lines in [parked, stopped] {
2424 if lines.is_empty() {
2425 continue;
2426 }
2427 println!();
2428 for line in lines {
2429 println!("{line}");
2430 }
2431 }
2432 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded) || !stopped.is_empty());
2433 issue_exit.max(report_writes(repo))
2434}
2435
2436fn report_writes(repo: &Repo) -> i32 {
2441 let writes = repo.write_summary();
2442 if let Some(line) = write_summary_line(writes) {
2443 println!("\n{line}");
2444 }
2445 write_exit_code(writes)
2446}
2447
2448fn write_summary_line(writes: WriteSummary) -> Option<String> {
2449 (writes.attempted > 0).then(|| {
2450 format!(
2451 "writes: {} attempted, {} succeeded, {} failed",
2452 writes.attempted,
2453 writes.succeeded(),
2454 writes.failed
2455 )
2456 })
2457}
2458
2459fn write_exit_code(writes: WriteSummary) -> i32 {
2460 i32::from(writes.failed > 0)
2461}
2462
2463fn report_item(title: &str, file: &str) -> String {
2464 match file.trim() {
2465 "" => title.to_string(),
2466 location => format!("{title} ({location})"),
2467 }
2468}
2469
2470#[cfg(test)]
2471mod tests {
2472 use super::*;
2473 use clap::CommandFactory;
2474
2475 fn planned(issue: i64, depends_on: Vec<i64>) -> PlanItem {
2476 PlanItem {
2477 issue,
2478 title: format!("issue {issue}"),
2479 complexity: crate::model::Complexity::S,
2480 risk: crate::model::Risk::Low,
2481 depends_on,
2482 reason: "worth doing".into(),
2483 }
2484 }
2485
2486 #[test]
2495 fn a_dependent_waits_for_a_dependency_that_has_not_landed() {
2496 let plan = Plan {
2497 order: vec![planned(9, vec![]), planned(10, vec![9])],
2498 ..Plan::default()
2499 };
2500 let mut landed = BTreeSet::new();
2501
2502 let why = held_back(&plan.order[1], &plan, &landed).expect("held");
2503 assert!(why.contains("#9"), "{why}");
2504 assert!(why.contains("has not merged"), "{why}");
2505 assert!(held_back(&plan.order[0], &plan, &landed).is_none());
2506
2507 landed.insert(9);
2509 assert!(held_back(&plan.order[1], &plan, &landed).is_none());
2510 }
2511
2512 #[test]
2515 fn a_dependent_on_a_declined_or_contested_issue_is_held_with_the_reason() {
2516 let declined = Plan {
2517 order: vec![planned(10, vec![9])],
2518 skipped: vec![crate::model::SkippedItem {
2519 issue: 9,
2520 title: "issue 9".into(),
2521 reasons: Default::default(),
2522 tracker: false,
2523 }],
2524 ..Plan::default()
2525 };
2526 let why = held_back(&declined.order[0], &declined, &BTreeSet::new()).expect("held");
2527 assert!(why.contains("declined by both reviewers"), "{why}");
2528
2529 let contested = Plan {
2530 order: vec![planned(10, vec![9])],
2531 contested: vec![crate::model::ContestedItem {
2532 issue: 9,
2533 title: "issue 9".into(),
2534 positions: Default::default(),
2535 reasons: Default::default(),
2536 note: None,
2537 }],
2538 ..Plan::default()
2539 };
2540 let why = held_back(&contested.order[0], &contested, &BTreeSet::new()).expect("held");
2541 assert!(why.contains("disagree about #9"), "{why}");
2542 }
2543
2544 #[test]
2548 fn a_dependency_outside_the_run_does_not_hold_anything() {
2549 let plan = Plan {
2550 order: vec![planned(10, vec![900])],
2551 ..Plan::default()
2552 };
2553 assert!(held_back(&plan.order[0], &plan, &BTreeSet::new()).is_none());
2554 }
2555
2556 #[test]
2562 fn doctor_says_when_a_scheduled_effort_is_not_one_the_agent_accepts() {
2563 let text = "\
2564[agents.claude]
2565preset = \"claude\"
2566
2567[agents.codex]
2568preset = \"codex\"
2569
2570[loop.effort_schedule]
2571review_1 = \"ultra\"
2572";
2573 let cfg = config::parse(text).expect("parses");
2574 let said = unknown_effort_values(&cfg);
2575 assert_eq!(1, said.len(), "{said:?}");
2576 assert!(said[0].starts_with("claude"), "{}", said[0]);
2577 assert!(said[0].contains("ultra"), "{}", said[0]);
2578 assert!(said[0].contains("review_1"), "{}", said[0]);
2579 assert!(
2580 said[0].contains("[agents.claude.effort_schedule]"),
2581 "it has to say where to put the right value: {}",
2582 said[0]
2583 );
2584
2585 let quiet = text.replace(
2587 "[loop.effort_schedule]\nreview_1 = \"ultra\"\n",
2588 "[agents.claude.effort_schedule]\nreview_1 = \"xhigh\"\n\n\
2589 [agents.codex.effort_schedule]\nreview_1 = \"ultra\"\n",
2590 );
2591 assert!(unknown_effort_values(&config::parse(&quiet).expect("parses")).is_empty());
2592 }
2593
2594 #[test]
2595 fn a_contested_issue_reads_as_both_positions_with_their_reasons() {
2596 let mut positions_map = std::collections::BTreeMap::new();
2597 positions_map.insert("claude".to_string(), "do".to_string());
2598 positions_map.insert("codex".to_string(), "skip".to_string());
2599 let mut reasons = std::collections::BTreeMap::new();
2600 reasons.insert(
2601 "claude".to_string(),
2602 "the retry path is still wrong".to_string(),
2603 );
2604 reasons.insert(
2605 "codex".to_string(),
2606 "fixed in the rewrite last month".to_string(),
2607 );
2608
2609 let line = positions(&positions_map, &reasons);
2610 assert!(line.contains("claude says do"), "{line}");
2611 assert!(line.contains("codex says skip"), "{line}");
2612 assert!(line.contains("the retry path is still wrong"), "{line}");
2613 assert!(line.contains("fixed in the rewrite"), "{line}");
2614
2615 let bare = positions(&positions_map, &Default::default());
2617 assert!(bare.contains("claude says do"), "{bare}");
2618 }
2619
2620 #[test]
2621 fn report_items_include_their_location() {
2622 assert_eq!(
2623 "Same title (src/a.rs:10)",
2624 report_item("Same title", "src/a.rs:10")
2625 );
2626 assert_eq!("General point", report_item("General point", ""));
2627 }
2628
2629 #[test]
2630 fn every_attempted_write_failing_is_a_failed_run() {
2631 let writes = WriteSummary {
2632 attempted: 3,
2633 failed: 3,
2634 };
2635
2636 assert_eq!(1, write_exit_code(writes));
2637 assert_eq!(
2638 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
2639 write_summary_line(writes)
2640 );
2641 }
2642
2643 #[test]
2644 fn a_partial_write_failure_is_reported_after_the_run() {
2645 let writes = WriteSummary {
2646 attempted: 3,
2647 failed: 1,
2648 };
2649
2650 assert_eq!(1, write_exit_code(writes));
2651 assert_eq!(2, writes.succeeded());
2652 }
2653
2654 #[test]
2655 fn the_parser_is_internally_consistent() {
2656 Cli::command().debug_assert();
2657 }
2658
2659 #[test]
2660 fn quiet_is_accepted_before_or_after_the_subcommand() {
2661 for argv in [
2662 vec!["spar", "--quiet", "run", "42"],
2663 vec!["spar", "run", "42", "--quiet"],
2664 vec!["spar", "resume", "--quiet"],
2665 vec!["spar", "init", "-q"],
2666 ] {
2667 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
2668 }
2669 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
2670 }
2671
2672 #[test]
2673 fn several_issue_numbers_are_accepted() {
2674 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
2675 match cli.command {
2676 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
2677 other => panic!("{other:?}"),
2678 }
2679 }
2680
2681 #[test]
2682 fn issue_numbers_and_flags_can_be_interleaved() {
2683 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
2684 match cli.command {
2685 Command::Run {
2686 issues, loop_flags, ..
2687 } => {
2688 assert_eq!(vec![42, 51], issues);
2689 assert!(loop_flags.auto_merge);
2690 }
2691 other => panic!("{other:?}"),
2692 }
2693 }
2694
2695 #[test]
2696 fn every_command_that_reads_a_config_accepts_one() {
2697 for argv in [
2698 vec!["spar", "run", "42"],
2699 vec!["spar", "triage"],
2700 vec!["spar", "resume"],
2701 vec!["spar", "followup"],
2702 vec!["spar", "checkin"],
2703 vec!["spar", "split"],
2704 vec!["spar", "brainstorm"],
2705 vec!["spar", "clean"],
2706 vec!["spar", "doctor"],
2707 ] {
2708 let mut full = argv.clone();
2709 full.extend(["--config", "other.toml"]);
2710 let cli = Cli::parse_from(&full);
2711 let config = match cli.command {
2712 Command::Run { common, .. }
2713 | Command::Triage { common, .. }
2714 | Command::Resume { common, .. }
2715 | Command::Followup { common, .. }
2716 | Command::Split { common, .. }
2717 | Command::Brainstorm { common, .. }
2718 | Command::Checkin { common, .. } => common.config,
2719 Command::Clean { config, .. } | Command::Doctor { config } => config,
2720 other => panic!("{other:?}"),
2721 };
2722 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2723 }
2724 }
2725
2726 #[test]
2727 fn auto_merge_is_off_unless_asked_for() {
2728 let cli = Cli::parse_from(["spar", "run"]);
2729 match cli.command {
2730 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2731 other => panic!("{other:?}"),
2732 }
2733 }
2734
2735 #[test]
2738 fn every_command_that_reads_a_config_takes_instructions() {
2739 for argv in [
2742 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2743 vec![
2744 "spar",
2745 "triage",
2746 "7",
2747 "--instructions",
2748 "Do not wait for CI.",
2749 ],
2750 vec![
2751 "spar",
2752 "resume",
2753 "7",
2754 "--instructions",
2755 "Do not wait for CI.",
2756 ],
2757 vec![
2758 "spar",
2759 "review",
2760 "7",
2761 "--instructions",
2762 "Do not wait for CI.",
2763 ],
2764 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2765 vec![
2766 "spar",
2767 "checkin",
2768 "7",
2769 "--instructions",
2770 "Do not wait for CI.",
2771 ],
2772 vec![
2773 "spar",
2774 "split",
2775 "7",
2776 "--instructions",
2777 "Do not wait for CI.",
2778 ],
2779 vec![
2780 "spar",
2781 "brainstorm",
2782 "a",
2783 "subject",
2784 "--instructions",
2785 "Do not wait for CI.",
2786 ],
2787 ] {
2788 let parsed = Cli::parse_from(&argv);
2789 let common = match parsed.command {
2790 Command::Run { common, .. }
2791 | Command::Triage { common, .. }
2792 | Command::Resume { common, .. }
2793 | Command::Review { common, .. }
2794 | Command::Followup { common, .. }
2795 | Command::Split { common, .. }
2796 | Command::Brainstorm { common, .. }
2797 | Command::Checkin { common, .. } => common,
2798 other => panic!("{other:?}"),
2799 };
2800 assert_eq!(
2801 Some("Do not wait for CI."),
2802 common.instructions.as_deref(),
2803 "{argv:?}"
2804 );
2805 }
2806 }
2807
2808 #[test]
2809 fn the_two_close_skipped_flags_are_mutually_exclusive() {
2810 assert!(
2811 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2812 );
2813 }
2814
2815 #[test]
2818 fn close_skipped_is_offered_only_where_it_means_something() {
2819 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2820 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2821 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2823 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2824 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2825 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2826 }
2827
2828 #[test]
2831 fn brainstorm_takes_words_or_nothing() {
2832 match Cli::parse_from(["spar", "brainstorm", "novel", "uses", "of", "script"]).command {
2833 Command::Brainstorm { topic, ideas, .. } => {
2834 assert_eq!(vec!["novel", "uses", "of", "script"], topic);
2835 assert_eq!(5, ideas);
2836 }
2837 other => panic!("{other:?}"),
2838 }
2839 match Cli::parse_from(["spar", "brainstorm", "--ideas", "3", "--max-rounds", "2"]).command {
2840 Command::Brainstorm {
2841 topic,
2842 ideas,
2843 max_rounds,
2844 ..
2845 } => {
2846 assert!(topic.is_empty());
2847 assert_eq!(3, ideas);
2848 assert_eq!(Some(2), max_rounds);
2849 }
2850 other => panic!("{other:?}"),
2851 }
2852 }
2853
2854 #[test]
2858 fn filing_from_a_saved_session_needs_file_issues_and_writes_no_session() {
2859 assert!(Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md"]).is_err());
2860 assert!(
2861 Cli::try_parse_from(["spar", "brainstorm", "--from", "s.md", "--file-issues"]).is_ok()
2862 );
2863 assert!(Cli::try_parse_from([
2864 "spar",
2865 "brainstorm",
2866 "--from",
2867 "s.md",
2868 "--file-issues",
2869 "--out",
2870 "t.md"
2871 ])
2872 .is_err());
2873 assert!(
2874 Cli::try_parse_from(["spar", "brainstorm", "x", "--out", "t.md", "--file-issues"])
2875 .is_ok()
2876 );
2877 }
2878
2879 #[test]
2882 fn followup_takes_no_numbers() {
2883 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
2884 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
2885 }
2886
2887 #[test]
2890 fn the_two_stopping_points_are_mutually_exclusive() {
2891 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
2892 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
2893 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
2894 }
2895
2896 #[test]
2897 fn the_close_skipped_pair_resolves_to_a_tristate() {
2898 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
2899 Command::Run { triage_flags, .. } => {
2900 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
2901 (true, _) => Some(true),
2902 (_, true) => Some(false),
2903 _ => None,
2904 }
2905 }
2906 other => panic!("{other:?}"),
2907 };
2908 assert_eq!(None, read(&["spar", "run"]));
2909 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
2910 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
2911 }
2912
2913 #[test]
2914 fn the_default_limit_is_twenty() {
2915 let cli = Cli::parse_from(["spar", "run"]);
2916 match cli.command {
2917 Command::Run { common, .. } => assert_eq!(20, common.limit),
2918 other => panic!("{other:?}"),
2919 }
2920 }
2921
2922 #[test]
2923 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
2924 assert!(matches!(
2925 Cli::parse_from(["spar", "scrub-filter"]).command,
2926 Command::ScrubFilter
2927 ));
2928 let help = Cli::command().render_long_help().to_string();
2929 assert!(
2930 !help.contains("scrub-filter"),
2931 "it is plumbing, not a command"
2932 );
2933 }
2934
2935 #[test]
2936 fn review_takes_pr_numbers_and_a_dry_run() {
2937 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
2938 match cli.command {
2939 Command::Review { items, dry_run, .. } => {
2940 assert_eq!(vec![101, 102], items);
2941 assert!(dry_run);
2942 }
2943 other => panic!("{other:?}"),
2944 }
2945 }
2946
2947 #[test]
2948 fn review_posts_unless_told_not_to() {
2949 match Cli::parse_from(["spar", "review", "101"]).command {
2950 Command::Review { dry_run, .. } => assert!(!dry_run),
2951 other => panic!("{other:?}"),
2952 }
2953 }
2954
2955 #[test]
2956 fn review_with_no_numbers_is_allowed() {
2957 match Cli::parse_from(["spar", "review"]).command {
2958 Command::Review { items, .. } => assert!(items.is_empty()),
2959 other => panic!("{other:?}"),
2960 }
2961 }
2962
2963 #[test]
2964 fn review_takes_its_own_round_budget() {
2965 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
2966 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
2967 other => panic!("{other:?}"),
2968 }
2969 }
2970
2971 #[test]
2972 fn checkin_takes_pr_numbers_and_a_dry_run() {
2973 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
2974 Command::Checkin { items, dry_run, .. } => {
2975 assert_eq!(vec![108, 112], items);
2976 assert!(dry_run);
2977 }
2978 other => panic!("{other:?}"),
2979 }
2980 match Cli::parse_from(["spar", "checkin"]).command {
2981 Command::Checkin { items, dry_run, .. } => {
2982 assert!(items.is_empty());
2983 assert!(!dry_run);
2984 }
2985 other => panic!("{other:?}"),
2986 }
2987 }
2988
2989 #[test]
2995 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
2996 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
2997 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
2998 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
2999 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
3000 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
3002 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
3003 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
3004 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
3005 }
3006
3007 #[test]
3008 fn resume_takes_a_next_override() {
3009 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
3010 match cli.command {
3011 Command::Resume {
3012 prs, next_actor, ..
3013 } => {
3014 assert_eq!(vec![108], prs);
3015 assert_eq!(Some("codex".to_string()), next_actor);
3016 }
3017 other => panic!("{other:?}"),
3018 }
3019 }
3020}
3021
3022#[cfg(test)]
3023mod absorb_tests {
3024 use super::*;
3025
3026 #[test]
3027 fn absorb_is_off_unless_asked_for() {
3028 match Cli::parse_from(["spar", "run"]).command {
3029 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
3030 other => panic!("{other:?}"),
3031 }
3032 }
3033
3034 #[test]
3035 fn absorb_takes_a_wave_count() {
3036 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
3037 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
3038 other => panic!("{other:?}"),
3039 }
3040 }
3041
3042 #[test]
3043 fn absorb_is_only_offered_where_issues_are_worked() {
3044 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
3045 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
3046 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
3047 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
3050 }
3051}
3052
3053#[cfg(test)]
3054mod split_tests {
3055 use super::*;
3056
3057 #[test]
3058 fn split_takes_numbers_of_either_kind() {
3059 match Cli::parse_from(["spar", "split", "10", "12"]).command {
3060 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
3061 other => panic!("{other:?}"),
3062 }
3063 }
3064
3065 #[test]
3068 fn split_with_no_numbers_is_allowed() {
3069 match Cli::parse_from(["spar", "split"]).command {
3070 Command::Split {
3071 items,
3072 dry_run,
3073 again,
3074 ..
3075 } => {
3076 assert!(items.is_empty());
3077 assert!(!dry_run);
3078 assert!(!again);
3079 }
3080 other => panic!("{other:?}"),
3081 }
3082 }
3083
3084 #[test]
3088 fn split_can_be_told_to_write_nothing() {
3089 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
3090 Command::Split { dry_run, .. } => assert!(dry_run),
3091 other => panic!("{other:?}"),
3092 }
3093 }
3094
3095 #[test]
3098 fn split_can_be_told_to_split_something_it_already_split() {
3099 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
3100 Command::Split { again, .. } => assert!(again),
3101 other => panic!("{other:?}"),
3102 }
3103 }
3104
3105 #[test]
3108 fn split_takes_the_flags_that_govern_picking() {
3109 match Cli::parse_from([
3110 "spar",
3111 "split",
3112 "--limit",
3113 "5",
3114 "--min-number",
3115 "480",
3116 "--config",
3117 "other.toml",
3118 ])
3119 .command
3120 {
3121 Command::Split { common, .. } => {
3122 assert_eq!(5, common.limit);
3123 assert_eq!(Some(480), common.min_number);
3124 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
3125 }
3126 other => panic!("{other:?}"),
3127 }
3128 }
3129
3130 #[test]
3133 fn split_offers_no_flag_that_would_make_it_a_second_run() {
3134 for flag in [
3135 vec!["--auto-merge"],
3136 vec!["--max-rounds", "2"],
3137 vec!["--close-skipped"],
3138 vec!["--no-close-skipped"],
3139 vec!["--keep-worktrees"],
3140 ] {
3141 let mut argv = vec!["spar", "split", "10"];
3142 argv.extend(flag.iter().copied());
3143 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
3144 }
3145 }
3146}
3147
3148#[cfg(test)]
3149mod min_number_tests {
3150 use super::*;
3151
3152 fn read(argv: &[&str]) -> Option<i64> {
3153 match Cli::parse_from(argv).command {
3154 Command::Run { common, .. }
3155 | Command::Triage { common, .. }
3156 | Command::Resume { common, .. }
3157 | Command::Review { common, .. }
3158 | Command::Followup { common, .. }
3159 | Command::Split { common, .. }
3160 | Command::Checkin { common, .. } => common.min_number,
3161 other => panic!("{other:?}"),
3162 }
3163 }
3164
3165 #[test]
3166 fn there_is_no_floor_unless_one_is_asked_for() {
3167 assert_eq!(None, read(&["spar", "run"]));
3168 }
3169
3170 #[test]
3171 fn every_command_that_picks_for_itself_accepts_a_floor() {
3172 for cmd in ["run", "triage", "resume", "review", "checkin", "split"] {
3173 assert_eq!(
3174 Some(480),
3175 read(&["spar", cmd, "--min-number", "480"]),
3176 "{cmd}"
3177 );
3178 }
3179 }
3180}
3181
3182#[cfg(test)]
3183mod run_targets_tests {
3184 use super::*;
3185
3186 fn covered(pairs: &[(i64, i64)]) -> BTreeMap<i64, i64> {
3187 pairs.iter().copied().collect()
3188 }
3189
3190 #[test]
3195 fn an_issue_its_named_pull_request_closes_is_not_worked_again() {
3196 let kept = issues_not_on_named_prs(&[772, 783, 784], &covered(&[(772, 786)]));
3197
3198 assert_eq!(vec![783, 784], kept);
3199 }
3200
3201 #[test]
3202 fn an_issue_no_named_pull_request_closes_is_still_worked() {
3203 let kept = issues_not_on_named_prs(&[783, 784, 771], &covered(&[(772, 786)]));
3204
3205 assert_eq!(vec![783, 784, 771], kept);
3206 }
3207
3208 #[test]
3211 fn an_unreadable_pull_request_drops_no_issue() {
3212 let kept = issues_not_on_named_prs(&[772, 783], &covered(&[]));
3213
3214 assert_eq!(vec![772, 783], kept);
3215 }
3216
3217 #[test]
3219 fn every_issue_one_pull_request_closes_is_taken_by_it() {
3220 let kept = issues_not_on_named_prs(&[771, 772, 783], &covered(&[(771, 786), (772, 786)]));
3221
3222 assert_eq!(vec![783], kept);
3223 }
3224}
3225
3226#[cfg(test)]
3227mod settings_block_tests {
3228 use super::*;
3229
3230 fn written(line: &str) -> String {
3233 let after = line.split_once('=').expect("an assignment").1;
3234 let mut quoted = false;
3235 for (i, c) in after.char_indices() {
3236 match c {
3237 '"' => quoted = !quoted,
3238 '#' if !quoted => return after[..i].trim().to_string(),
3239 _ => {}
3240 }
3241 }
3242 after.trim().to_string()
3243 }
3244
3245 fn line_for(text: &str, key: &str) -> String {
3246 text.lines()
3247 .find(|l| {
3248 let bare = l.trim_start().trim_start_matches('#').trim_start();
3249 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
3250 })
3251 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
3252 .to_string()
3253 }
3254
3255 #[test]
3262 fn every_value_it_offers_is_the_default_it_actually_has() {
3263 let text = settings_block("claude");
3264 for option in config::known_options() {
3265 if option.section == "loop.effort_schedule" {
3269 continue;
3270 }
3271 let line = line_for(&text, &option.key);
3272 assert_eq!(
3273 option.default,
3274 written(&line),
3275 "the generated config offers `{}`, but the default is {}",
3276 line.trim(),
3277 option.default
3278 );
3279 }
3280 }
3281
3282 #[test]
3286 fn it_offers_every_option_the_parser_knows_about() {
3287 let text = settings_block("claude");
3288 let missing: Vec<String> = config::unmentioned_options(&text)
3289 .into_iter()
3290 .map(|o| format!("[{}] {}", o.section, o.key))
3291 .collect();
3292 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
3293 }
3294
3295 #[test]
3299 fn every_option_it_offers_can_be_uncommented_and_still_load() {
3300 let mut text = String::from(
3301 "[agents.claude]\ncommand = [\"claude\"]\n\n\
3302 [agents.codex]\ncommand = [\"codex\"]\n\n",
3303 );
3304 for line in settings_block("claude").lines() {
3305 text.push_str(uncomment(line).unwrap_or(line));
3306 text.push('\n');
3307 }
3308 let cfg = config::parse(&text).expect("a config of its own suggestions");
3309 assert_eq!("claude", cfg.first_implementor);
3310 }
3311
3312 fn uncomment(line: &str) -> Option<&str> {
3315 let bare = line.trim_start().strip_prefix('#')?.trim_start();
3316 let key = bare.split_once('=')?.0.trim();
3319 let named = !key.is_empty()
3320 && key
3321 .chars()
3322 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
3323 named.then_some(bare)
3324 }
3325
3326 #[test]
3327 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
3328 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
3329 }
3330
3331 #[test]
3336 fn a_note_sits_above_the_option_it_describes() {
3337 let text = settings_block("claude");
3338 assert!(
3339 text.lines().all(|l| l.chars().count() <= 80),
3340 "a line runs off the edge:\n{text}"
3341 );
3342 assert!(
3343 text.lines().all(|l| !l.starts_with(' ')),
3344 "a line is indented, so the columns are back:\n{text}"
3345 );
3346
3347 let lines: Vec<&str> = text.lines().collect();
3349 let at = lines
3350 .iter()
3351 .position(|l| l.starts_with("max_rounds"))
3352 .expect("max_rounds");
3353 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
3354 assert!(
3355 lines[at - 1].contains("lifetime cap"),
3356 "the note above it is the end of its own note: {:?}",
3357 lines[at - 1]
3358 );
3359 }
3360
3361 #[test]
3363 fn every_note_starts_as_a_sentence() {
3364 for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
3365 let first = note.chars().next().expect("a note");
3366 assert!(
3367 first.is_uppercase(),
3368 "{key} reads as a margin scribble rather than a sentence: {note}"
3369 );
3370 }
3371 }
3372}
3373
3374#[cfg(test)]
3375mod agent_block_tests {
3376 use super::*;
3377
3378 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
3379 let mut spec: config::AgentSpec =
3380 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
3381 .try_into()
3382 .expect("builds");
3383 spec.models = models.iter().map(|s| s.to_string()).collect();
3384 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
3385 spec
3386 }
3387
3388 #[test]
3392 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
3393 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
3394 assert!(!block.contains("..."), "{block}");
3395 assert!(!block.contains("effort"), "{block}");
3396 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
3397 }
3398
3399 #[test]
3402 fn only_the_options_that_follow_are_introduced() {
3403 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
3404 assert!(model_only.contains("Omit model to use"), "{model_only}");
3405 assert!(!model_only.contains("Omit effort"), "{model_only}");
3406
3407 let both = agent_block("claude", &spec(&["fable"], &["high"]));
3408 assert!(both.contains("Omit model to use"), "{both}");
3409 assert!(both.contains("Omit effort to use"), "{both}");
3410 }
3411
3412 #[test]
3415 fn a_preset_with_no_hints_still_writes_a_usable_block() {
3416 let block = agent_block("gemini", &spec(&[], &[]));
3417 assert!(!block.contains("..."), "{block}");
3418 assert!(!block.contains("Omit"), "{block}");
3419 assert!(
3420 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
3421 "{block}"
3422 );
3423 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
3425 assert!(block.contains("# timeout = "), "{block}");
3426 }
3427
3428 #[test]
3431 fn the_timeout_offered_is_the_one_the_agent_would_use() {
3432 let mut spec = spec(&["a"], &[]);
3433 spec.timeout = 7200;
3434 assert!(
3435 agent_block("custom", &spec).contains("# timeout = 7200"),
3436 "the generator kept its own copy"
3437 );
3438 }
3439
3440 #[test]
3442 fn alternatives_are_listed_only_when_there_are_any() {
3443 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
3444 let single = agent_block("b", &spec(&["only"], &[]));
3445 assert!(!single.contains("One of:"), "{single}");
3446 }
3447
3448 #[test]
3452 fn the_presets_note_is_said_once() {
3453 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
3454 spec.options_note = Some("Check the current sets with: mytool --help".into());
3455 let block = agent_block("mytool", &spec);
3456 assert_eq!(
3457 1,
3458 block.matches("Check the current sets").count(),
3459 "{block}"
3460 );
3461 }
3462}