1use std::collections::{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::checkin;
11use crate::config::{self, Config};
12use crate::error::Result;
13use crate::followups;
14use crate::model::{Issue, IssueRun, ItemKind, Plan, Status};
15use crate::proc::{self, ExecOpts};
16use crate::repo::{Repo, WriteSummary};
17use crate::review;
18use crate::review_only;
19use crate::split;
20use crate::style;
21use crate::tracker;
22use crate::triage;
23use crate::{bail, log, logdim, logging, logwarn, spar_err};
24
25pub const VERSION: &str = env!("CARGO_PKG_VERSION");
26
27#[derive(Parser, Debug)]
28#[command(
29 name = "spar",
30 version = VERSION,
31 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
32 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
33 pull request converges. Neither agent reviews its own most recent edit.\n\n\
34 Arguments are issue numbers for `run` and `triage`, and pull request numbers \
35 for `resume`, `review`, and `checkin`. `split` takes either. Omit them and \
36 spar takes everything open, up to --limit. `split` applies that limit once to \
37 issues and once to pull requests. `followup` takes none: it works the queue in \
38 .spar/followups.md, and an entry there has no number to name.",
39 max_term_width = 96
40)]
41pub struct Cli {
42 #[arg(short, long, global = true)]
44 pub quiet: bool,
45
46 #[command(subcommand)]
47 pub command: Command,
48}
49
50#[derive(Subcommand, Debug)]
51pub enum Command {
52 Run {
54 issues: Vec<i64>,
56 #[command(flatten)]
57 common: Common,
58 #[command(flatten)]
59 loop_flags: LoopFlags,
60 #[command(flatten)]
61 triage_flags: TriageFlags,
62 #[arg(long, default_value = "plan.json")]
64 plan_out: PathBuf,
65 #[arg(long)]
67 no_worktrees: bool,
68 },
69
70 Followup {
82 #[command(flatten)]
83 common: Common,
84 #[command(flatten)]
85 loop_flags: LoopFlags,
86 #[command(flatten)]
87 triage_flags: TriageFlags,
88 #[arg(long, value_name = "PATH")]
90 file: Option<PathBuf>,
91 #[arg(long)]
93 screen_only: bool,
94 #[arg(long, conflicts_with = "screen_only")]
96 file_only: bool,
97 #[arg(long, default_value = "plan.json")]
99 plan_out: PathBuf,
100 #[arg(long)]
102 no_worktrees: bool,
103 },
104
105 Triage {
107 issues: Vec<i64>,
109 #[command(flatten)]
110 common: Common,
111 #[arg(long, default_value = "plan.json")]
112 plan_out: PathBuf,
113 },
114
115 Resume {
117 prs: Vec<i64>,
119 #[command(flatten)]
120 common: Common,
121 #[command(flatten)]
122 loop_flags: LoopFlags,
123 #[arg(long = "next", value_name = "AGENT")]
125 next_actor: Option<String>,
126 },
127
128 Checkin {
136 items: Vec<i64>,
139 #[command(flatten)]
140 common: Common,
141 #[arg(long)]
143 dry_run: bool,
144 #[arg(long)]
146 reply_only: bool,
147 #[arg(long)]
150 any_author: bool,
151 #[arg(long)]
153 again: bool,
154 #[arg(long)]
156 keep_worktrees: bool,
157 },
158
159 Review {
164 items: Vec<i64>,
167 #[command(flatten)]
168 common: Common,
169 #[arg(long)]
171 dry_run: bool,
172 #[arg(long)]
175 max_rounds: Option<u32>,
176 },
177
178 Split {
195 items: Vec<i64>,
198 #[command(flatten)]
199 common: Common,
200 #[arg(long)]
202 dry_run: bool,
203 #[arg(long)]
206 again: bool,
207 },
208
209 Post {
214 #[arg(required = true)]
216 prs: Vec<i64>,
217 #[arg(long, default_value = ".")]
218 repo: PathBuf,
219 #[arg(long)]
220 config: Option<PathBuf>,
221 #[arg(long, value_name = "PATH")]
223 file: Option<PathBuf>,
224 #[arg(long)]
226 dry_run: bool,
227 },
228
229 Init {
234 #[arg(long, default_value = "spar.toml")]
235 out: PathBuf,
236 #[arg(long)]
238 force: bool,
239 #[arg(long, conflicts_with = "force")]
242 update: bool,
243 },
244
245 Clean {
247 #[arg(long, default_value = ".")]
248 repo: PathBuf,
249 #[arg(long)]
250 config: Option<PathBuf>,
251 #[arg(long)]
254 all: bool,
255 #[arg(long)]
257 pr_state: bool,
258 },
259
260 Doctor {
262 #[arg(long)]
263 config: Option<PathBuf>,
264 },
265
266 #[command(hide = true)]
270 ScrubFilter,
271}
272
273#[derive(Args, Debug, Clone)]
274pub struct Common {
275 #[arg(long, default_value = ".")]
277 pub repo: PathBuf,
278 #[arg(long)]
280 pub config: Option<PathBuf>,
281 #[arg(long)]
283 pub base: Option<String>,
284 #[arg(long)]
286 pub first: Option<String>,
287 #[arg(long, default_value_t = 20)]
290 pub limit: usize,
291 #[arg(long, value_name = "N")]
294 pub min_number: Option<i64>,
295 #[arg(long, value_name = "TEXT")]
298 pub instructions: Option<String>,
299}
300
301#[derive(Args, Debug, Clone)]
302pub struct LoopFlags {
303 #[arg(long)]
306 pub max_rounds: Option<u32>,
307 #[arg(long)]
309 pub auto_merge: bool,
310 #[arg(long)]
312 pub keep_worktrees: bool,
313 #[arg(long, value_name = "N")]
316 pub absorb: Option<u32>,
317}
318
319#[derive(Args, Debug, Clone)]
322pub struct TriageFlags {
323 #[arg(long, conflicts_with = "no_close_skipped")]
325 pub close_skipped: bool,
326 #[arg(long)]
328 pub no_close_skipped: bool,
329}
330
331pub fn main() -> i32 {
336 let cli = Cli::parse();
337 logging::init_color();
338 logging::set_quiet(cli.quiet);
339
340 match dispatch(cli) {
341 Ok(code) => code,
342 Err(e) => {
343 logging::error(e.to_string());
344 2
345 }
346 }
347}
348
349fn dispatch(cli: Cli) -> Result<i32> {
350 match cli.command {
351 Command::ScrubFilter => cmd_scrub_filter(),
352 Command::Doctor { config } => cmd_doctor(config.as_deref()),
353 Command::Review {
354 items,
355 common,
356 dry_run,
357 max_rounds,
358 } => {
359 let overrides = Overrides {
360 max_rounds,
361 ..Overrides::default()
362 };
363 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
364 let numbers = if items.is_empty() {
365 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
366 if found.is_empty() {
367 log!("no open PRs");
368 return Ok(0);
369 }
370 log!("no PRs given, reviewing {} open", found.len());
371 found
372 } else {
373 items
374 };
375 let sorted = classify(&repo, &numbers)?;
376 let mut targets = sorted.prs;
377 for number in sorted.issues {
378 match repo.open_pr_for_issue(number) {
379 Some(pr) => {
380 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
381 targets.push(pr.number);
382 }
383 None => logwarn!("#{number} is an issue with no open pull request to review"),
384 }
385 }
386 let mut results = Vec::new();
387 for number in targets {
388 results.push(review_only::review_pr(
389 &agents, &cfg, &repo, number, dry_run,
390 ));
391 }
392 if results.is_empty() {
393 return Ok(0);
394 }
395 Ok(report(&results, &cfg, &repo))
396 }
397
398 Command::Checkin {
399 items,
400 common,
401 dry_run,
402 reply_only,
403 any_author,
404 again,
405 keep_worktrees,
406 } => {
407 let overrides = Overrides {
408 keep_worktrees: keep_worktrees.then_some(true),
409 ..Overrides::default()
410 };
411 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
412 let mode = checkin::Mode {
413 dry_run,
414 reply_only,
415 trust: if any_author {
416 crate::config::Trust::Anyone
417 } else {
418 cfg.loop_cfg.checkin_trust
419 },
420 again,
421 resolve: cfg.loop_cfg.checkin_resolve,
422 posts: checkin::posts(&cfg),
423 };
424 let numbers = if items.is_empty() {
425 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
426 if found.is_empty() {
427 log!("no open PRs");
428 return Ok(0);
429 }
430 log!(
431 "no PRs given, checking in on {} open: {}",
432 found.len(),
433 found
434 .iter()
435 .map(|n| format!("#{n}"))
436 .collect::<Vec<_>>()
437 .join(", ")
438 );
439 found
440 } else {
441 items
442 };
443 let sorted = classify(&repo, &numbers)?;
444 let mut results = Vec::new();
445 for number in sorted.prs {
446 results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
447 }
448 for number in sorted.issues {
452 match repo.open_pr_for_issue(number) {
453 Some(pr) => {
454 log!(
455 "#{number} is an issue; checking in on its open PR {}",
456 pr.url
457 );
458 results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
459 }
460 None => {
461 results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
462 }
463 }
464 }
465 if results.is_empty() {
466 return Ok(0);
467 }
468 Ok(report(&results, &cfg, &repo))
469 }
470
471 Command::Split {
472 items,
473 common,
474 dry_run,
475 again,
476 } => {
477 let (cfg, repo, agents) = prepare(&common, None)?;
478 let mode = split::Mode { dry_run, again };
479 let picked = if items.is_empty() {
480 pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
481 } else {
482 let sorted = classify(&repo, &items)?;
483 let mut out: Vec<(i64, ItemKind)> = sorted
484 .issues
485 .iter()
486 .map(|n| (*n, ItemKind::Issue))
487 .collect();
488 out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
489 out
490 };
491
492 let mut results = Vec::new();
493 let mut seen: BTreeSet<i64> = BTreeSet::new();
494 for (number, kind) in picked {
495 let target = match kind {
499 ItemKind::Pr => Some(number),
500 ItemKind::Issue => repo.open_pr_for_issue(number).map(|pr| {
501 log!("#{number} is an issue; splitting its open PR {}", pr.url);
502 pr.number
503 }),
504 };
505 if !seen.insert(target.unwrap_or(number)) {
510 logdim!("#{number} was already covered by this run, skipping it");
511 continue;
512 }
513 results.push(match target {
514 Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
515 None => split::split_issue(&agents, &cfg, &repo, number, &mode),
516 });
517 }
518 if results.is_empty() {
519 return Ok(0);
520 }
521 Ok(report(&results, &cfg, &repo))
522 }
523
524 Command::Post {
525 prs,
526 repo: repo_path,
527 config,
528 file,
529 dry_run,
530 } => cmd_post(
531 &prs,
532 &repo_path,
533 config.as_deref(),
534 file.as_deref(),
535 dry_run,
536 ),
537
538 Command::Init { out, force, update } => {
539 if update {
540 cmd_init_update(&out)
541 } else {
542 cmd_init(&out, force)
543 }
544 }
545 Command::Clean {
546 repo,
547 config,
548 all,
549 pr_state,
550 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
551 Command::Triage {
552 issues,
553 common,
554 plan_out,
555 } => {
556 let (cfg, repo, agents) = prepare(&common, None)?;
557 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
558 if numbers.is_empty() {
559 return Ok(0);
560 }
561 let sorted = classify(&repo, &numbers)?;
562 for number in &sorted.prs {
563 log!("#{number} is a pull request, nothing to triage");
564 }
565 if sorted.issues.is_empty() {
566 log!("no issues to triage");
567 return Ok(0);
568 }
569 let issues = repo.fetch_issues(&sorted.issues)?;
570 let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
574 if cfg.loop_cfg.decompose_trackers {
578 for item in plan.skipped.iter().filter(|i| i.tracker) {
579 tracker::preview(&cfg, &repo, item.issue);
580 }
581 }
582 Ok(0)
583 }
584 Command::Run {
585 issues,
586 common,
587 loop_flags,
588 triage_flags,
589 plan_out,
590 no_worktrees,
591 } => {
592 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
593 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
594 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
595 if numbers.is_empty() {
596 return Ok(0);
597 }
598 let sorted = classify(&repo, &numbers)?;
599 let mut results = Vec::new();
600 work_issues(
601 &agents,
602 &cfg,
603 &repo,
604 sorted.issues.clone(),
605 &plan_out,
606 &mut results,
607 )?;
608
609 for number in sorted.prs {
610 results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
611 }
612
613 if results.is_empty() {
614 log!("nothing scheduled");
615 return Ok(report_writes(&repo));
616 }
617 Ok(report(&results, &cfg, &repo))
618 }
619 Command::Followup {
620 common,
621 loop_flags,
622 triage_flags,
623 file,
624 screen_only,
625 file_only,
626 plan_out,
627 no_worktrees,
628 } => {
629 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
630 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
631 let path = file.unwrap_or_else(|| repo.followups_path());
632 let mode = match (screen_only, file_only) {
633 (true, _) => followups::Mode::ScreenOnly,
634 (_, true) => followups::Mode::FileOnly,
635 _ => followups::Mode::Work,
636 };
637 let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
638
639 let wave = followups::wave(&outcome);
640 if mode != followups::Mode::Work || wave.is_empty() {
641 return Ok(report_writes(&repo));
642 }
643 let mut results = Vec::new();
644 work_issues(&agents, &cfg, &repo, wave, &plan_out, &mut results)?;
645 if results.is_empty() {
646 log!("nothing scheduled");
647 return Ok(report_writes(&repo));
648 }
649 Ok(report(&results, &cfg, &repo))
650 }
651
652 Command::Resume {
653 prs,
654 common,
655 loop_flags,
656 next_actor,
657 } => {
658 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
659 if let Some(name) = &next_actor {
660 if !cfg.has_agent(name) {
661 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
662 }
663 }
664 let numbers = if prs.is_empty() {
665 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
666 if found.is_empty() {
667 log!("no open PRs");
668 return Ok(0);
669 }
670 log!(
671 "no PRs given, taking {} open: {}",
672 found.len(),
673 found
674 .iter()
675 .map(|n| format!("#{n}"))
676 .collect::<Vec<_>>()
677 .join(", ")
678 );
679 found
680 } else {
681 prs
682 };
683 let sorted = classify(&repo, &numbers)?;
684 let mut results = Vec::new();
685 for number in sorted.prs {
686 results.push(review::resume_pr(
687 &agents,
688 &cfg,
689 &repo,
690 number,
691 next_actor.as_deref(),
692 ));
693 }
694 for number in sorted.issues {
697 match repo.open_pr_for_issue(number) {
698 Some(pr) => {
699 log!("#{number} is an issue; continuing its open PR {}", pr.url);
700 results.push(review::resume_pr(
701 &agents,
702 &cfg,
703 &repo,
704 pr.number,
705 next_actor.as_deref(),
706 ));
707 }
708 None => logwarn!(
709 "#{number} is an issue with no open pull request. Use `spar run {number}` \
710 to implement it."
711 ),
712 }
713 }
714 if results.is_empty() {
715 return Ok(0);
716 }
717 Ok(report(&results, &cfg, &repo))
718 }
719 }
720}
721
722#[derive(Debug, Default, Clone)]
727struct Overrides {
728 max_rounds: Option<u32>,
729 auto_merge: Option<bool>,
730 keep_worktrees: Option<bool>,
731 worktrees: Option<bool>,
732 close_skipped: Option<bool>,
733 absorb: Option<u32>,
734}
735
736impl From<&LoopFlags> for Overrides {
737 fn from(flags: &LoopFlags) -> Self {
738 Self {
739 max_rounds: flags.max_rounds,
740 auto_merge: flags.auto_merge.then_some(true),
741 keep_worktrees: flags.keep_worktrees.then_some(true),
742 worktrees: None,
743 close_skipped: None,
744 absorb: flags.absorb,
745 }
746 }
747}
748
749impl Overrides {
750 fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
753 let mut over = Overrides::from(loop_flags);
754 over.worktrees = if no_worktrees { Some(false) } else { None };
755 over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
756 (true, _) => Some(true),
757 (_, true) => Some(false),
758 _ => None,
759 };
760 over
761 }
762}
763
764fn work_issues(
774 agents: &[Agent],
775 cfg: &Config,
776 repo: &Repo,
777 first_wave: Vec<i64>,
778 plan_out: &Path,
779 results: &mut Vec<IssueRun>,
780) -> Result<()> {
781 let mut handled: BTreeSet<i64> = BTreeSet::new();
782 let mut leftover: BTreeSet<i64> = BTreeSet::new();
783 let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
784 let mut plans_written = 0usize;
785
786 while let Some(mut wave) = queue.pop_front() {
793 wave.numbers.retain(|n| !handled.contains(n));
794 if wave.numbers.is_empty() {
795 continue;
796 }
797 match wave.parent {
798 Some(tracker) => log!(
799 "working {} item(s) from the checklist in #{tracker}: {}",
800 wave.numbers.len(),
801 numbers(&wave.numbers)
802 ),
803 None if wave.absorbed > 0 => log!(
804 "absorbing {} newly filed issue(s): {}",
805 wave.numbers.len(),
806 numbers(&wave.numbers)
807 ),
808 None => {}
809 }
810 handled.extend(wave.numbers.iter().copied());
811
812 let fetched = match repo.fetch_issues(&wave.numbers) {
813 Ok(fetched) => fetched,
814 Err(e) => {
815 logdim!("could not read the next wave: {e}");
816 continue;
817 }
818 };
819 let plan_path = if plans_written == 0 {
822 plan_out.to_path_buf()
823 } else {
824 plan_out.with_extension(format!("wave{plans_written}.json"))
825 };
826 plans_written += 1;
827 let plan = make_plan(agents, cfg, repo, &fetched, &plan_path)?;
828 act_on_plan(cfg, repo, &plan);
829
830 if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
834 for item in plan.skipped.iter().filter(|i| i.tracker) {
835 let children = tracker::decompose(cfg, repo, item.issue);
836 if !children.is_empty() {
837 queue.push_back(wave.child(item.issue, children));
838 }
839 }
840 }
841
842 let before = results.len();
843 for item in &plan.order {
844 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
845 continue;
846 };
847 results.push(review::run_issue(agents, cfg, repo, item, issue));
848 }
849
850 let filed: Vec<i64> = results[before..]
852 .iter()
853 .flat_map(|r| r.filed.iter())
854 .filter_map(|url| review::filed_issue_number(url))
855 .filter(|n| !handled.contains(n))
856 .collect::<BTreeSet<_>>()
857 .into_iter()
858 .collect();
859 if filed.is_empty() {
860 continue;
861 }
862 match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
863 true => queue.push_back(wave.absorb(filed)),
864 false => leftover.extend(filed),
865 }
866 }
867 if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
868 log!(
869 "{} issue(s) filed in the last wave were left for a later run: {}",
870 leftover.len(),
871 numbers(&leftover.iter().copied().collect::<Vec<_>>())
872 );
873 }
874 Ok(())
875}
876
877struct Wave {
879 numbers: Vec<i64>,
880 absorbed: u32,
885 parent: Option<i64>,
887}
888
889impl Wave {
890 fn first(numbers: Vec<i64>) -> Self {
891 Self {
892 numbers,
893 absorbed: 0,
894 parent: None,
895 }
896 }
897
898 fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
899 Self {
900 numbers,
901 absorbed: self.absorbed,
902 parent: Some(tracker),
903 }
904 }
905
906 fn absorb(&self, numbers: Vec<i64>) -> Self {
907 Self {
908 numbers,
909 absorbed: self.absorbed + 1,
910 parent: None,
911 }
912 }
913}
914
915fn numbers(items: &[i64]) -> String {
916 items
917 .iter()
918 .map(|n| format!("#{n}"))
919 .collect::<Vec<_>>()
920 .join(", ")
921}
922
923fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
924 let mut cfg = config::load(common.config.as_deref())?;
925
926 if let Some(first) = &common.first {
927 if !cfg.has_agent(first) {
928 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
929 }
930 cfg.first_implementor = first.clone();
931 }
932 if let Some(base) = &common.base {
933 cfg.loop_cfg.base_branch = base.clone();
934 }
935 if let Some(min) = common.min_number {
936 cfg.loop_cfg.min_number = min;
937 }
938 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
942 if !extra.is_empty() {
943 let standing = cfg.loop_cfg.instructions.trim();
944 cfg.loop_cfg.instructions = if standing.is_empty() {
945 extra.to_string()
946 } else {
947 format!("{standing}\n{extra}")
948 };
949 }
950 }
951 if let Some(over) = overrides {
952 if let Some(v) = over.max_rounds {
953 if v == 0 {
954 bail!("--max-rounds must be at least 1");
955 }
956 cfg.loop_cfg.max_rounds = v;
957 }
958 if let Some(v) = over.auto_merge {
959 cfg.loop_cfg.auto_merge = v;
960 }
961 if let Some(v) = over.keep_worktrees {
962 cfg.loop_cfg.keep_worktrees = v;
963 }
964 if let Some(v) = over.worktrees {
965 cfg.loop_cfg.worktrees = v;
966 }
967 if let Some(v) = over.close_skipped {
968 cfg.loop_cfg.close_skipped = v;
969 }
970 if let Some(v) = over.absorb {
971 cfg.loop_cfg.absorb_new_issues = v;
972 }
973 }
974
975 let repo = Repo::open(&common.repo, &cfg)?;
976 if common.base.is_none() {
977 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
978 }
979
980 let agents = agent::build(&cfg)?;
981 if let Some(warning) = agent::correlation_warning(&agents) {
982 logging::warn(warning);
983 }
984
985 for stale in repo.prune_worktrees(false) {
987 let what = if stale.starts_with("branch ") {
988 stale
989 } else {
990 format!("worktree {stale}")
991 };
992 logdim!("cleaned up finished {what}");
993 }
994
995 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
996 log!(
997 "agents: {}",
998 agents
999 .iter()
1000 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1001 .collect::<Vec<_>>()
1002 .join(", ")
1003 );
1004 Ok((cfg, repo, agents))
1005}
1006
1007fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1008 if !given.is_empty() {
1009 if min_number > 0 {
1011 let below: Vec<String> = given
1012 .iter()
1013 .filter(|n| **n < min_number)
1014 .map(|n| format!("#{n}"))
1015 .collect();
1016 if !below.is_empty() {
1017 logdim!(
1018 "{} below the #{min_number} floor, taking them because you named them",
1019 below.join(", ")
1020 );
1021 }
1022 }
1023 return Ok(given);
1024 }
1025 let found = repo.list_open_issues(limit, min_number)?;
1026 if found.is_empty() {
1027 log!("no open issues");
1028 return Ok(found);
1029 }
1030 log!(
1031 "no issues given, taking {} open: {}",
1032 found.len(),
1033 found
1034 .iter()
1035 .map(|n| format!("#{n}"))
1036 .collect::<Vec<_>>()
1037 .join(", ")
1038 );
1039 Ok(found)
1040}
1041
1042fn pick_for_split(
1055 agents: &[Agent],
1056 cfg: &Config,
1057 repo: &Repo,
1058 limit: usize,
1059 mode: &split::Mode,
1060) -> Result<Vec<(i64, ItemKind)>> {
1061 let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1062 let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1063 log!(
1064 "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1065 issues.len(),
1066 prs.len()
1067 );
1068 if issues.is_empty() && prs.is_empty() {
1069 return Ok(Vec::new());
1070 }
1071
1072 let issue_rows = repo.open_issue_rows();
1073 let pr_rows = repo.open_pr_rows();
1074 let mut candidates: Vec<split::Candidate> = Vec::new();
1075 let mut already = 0usize;
1076
1077 for number in &issues {
1078 let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1079 logdim!("could not read #{number}, leaving it alone");
1080 continue;
1081 };
1082 if split::already_split(row.body_text()) && !mode.again {
1085 already += 1;
1086 continue;
1087 }
1088 candidates.push(split::Candidate::from_issue(row));
1089 }
1090 for number in &prs {
1091 match pr_rows.iter().find(|p| p.number == *number) {
1092 Some(row) => candidates.push(split::Candidate::from_pr(row)),
1093 None => logdim!("could not read PR #{number}, leaving it alone"),
1094 }
1095 }
1096 if already > 0 {
1097 log!("{already} issue(s) already split, skipped. --again reopens them.");
1098 }
1099 if candidates.is_empty() {
1100 return Ok(Vec::new());
1101 }
1102
1103 let agent = agent::find(agents, &cfg.first_implementor)?;
1104 log!(
1105 "screening {} item(s) with {}",
1106 candidates.len(),
1107 agent.name()
1108 );
1109 let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1110
1111 let mut picked = Vec::new();
1112 for candidate in &candidates {
1113 match verdicts.iter().find(|v| v.item == candidate.number) {
1114 Some(v) if v.split => {
1115 log!(" split #{}: {}", candidate.number, v.reason.trim());
1116 picked.push((candidate.number, candidate.kind));
1117 }
1118 Some(v) => logdim!(" leave #{} whole: {}", candidate.number, v.reason.trim()),
1121 None => logdim!(" no verdict for #{}, leaving it whole", candidate.number),
1122 }
1123 }
1124 if picked.is_empty() {
1125 log!("nothing worth splitting");
1126 }
1127 Ok(picked)
1128}
1129
1130#[derive(Debug, Default)]
1136struct Sorted {
1137 issues: Vec<i64>,
1138 prs: Vec<i64>,
1139}
1140
1141fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1142 let mut sorted = Sorted::default();
1143 for number in numbers {
1144 match repo.item_kind(*number)? {
1145 ItemKind::Issue => sorted.issues.push(*number),
1146 ItemKind::Pr => sorted.prs.push(*number),
1147 }
1148 }
1149 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1150 log!(
1151 "{} issue(s) and {} pull request(s) given",
1152 sorted.issues.len(),
1153 sorted.prs.len()
1154 );
1155 }
1156 Ok(sorted)
1157}
1158
1159fn make_plan(
1160 agents: &[Agent],
1161 cfg: &Config,
1162 repo: &Repo,
1163 issues: &[Issue],
1164 plan_out: &Path,
1165) -> Result<Plan> {
1166 let plan = triage::triage(agents, cfg, repo, issues)?;
1167
1168 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1169 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1170 log!("plan written to {}", plan_out.display());
1171
1172 for item in &plan.order {
1173 log!(
1174 " do #{} [{}/{}] {}",
1175 item.issue,
1176 item.complexity,
1177 item.risk,
1178 item.title
1179 );
1180 }
1181 for item in &plan.skipped {
1182 if item.tracker {
1183 log!(
1184 " hold #{} (both reviewers: tracks work filed elsewhere)",
1185 item.issue
1186 );
1187 } else {
1188 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
1189 }
1190 }
1191 for item in &plan.contested {
1192 log!(" ?? #{} contested, parked for you to decide", item.issue);
1193 }
1194 Ok(plan)
1195}
1196
1197fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1207 for item in &plan.skipped {
1208 let body = review::skip_comment(item, &repo.style);
1209 let close = cfg.loop_cfg.close_skipped && !item.tracker;
1210 let outcome = if close {
1211 repo.close_issue(item.issue, &body)
1212 } else {
1213 repo.comment_issue(item.issue, &body)
1214 };
1215 match outcome {
1216 Ok(()) if close => log!(" closed #{}", item.issue),
1217 Ok(()) if item.tracker => {
1218 log!(
1219 " left #{} open, it tracks work filed elsewhere",
1220 item.issue
1221 )
1222 }
1223 Ok(()) => {}
1224 Err(e) => logdim!("could not update #{}: {e}", item.issue),
1225 }
1226 }
1227}
1228
1229fn cmd_scrub_filter() -> Result<i32> {
1234 let mut input = String::new();
1235 std::io::stdin()
1236 .read_to_string(&mut input)
1237 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1238 let out = style::scrub(&input, &crate::repo::style_from_env());
1239 let mut stdout = std::io::stdout();
1240 stdout
1241 .write_all(out.as_bytes())
1242 .and_then(|_| stdout.write_all(b"\n"))
1243 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1244 Ok(0)
1245}
1246
1247fn cmd_clean(
1248 repo_path: &Path,
1249 config_path: Option<&Path>,
1250 all: bool,
1251 pr_state: bool,
1252) -> Result<i32> {
1253 let cfg = config::load(config_path)?;
1254 let repo = Repo::open(repo_path, &cfg)?;
1255 let mut removed = repo.prune_worktrees(all);
1256 removed.extend(repo.prune_state());
1257 if pr_state {
1258 removed.extend(repo.prune_pr_state(None));
1259 }
1260 if removed.is_empty() {
1261 if repo.write_summary().failed > 0 {
1262 println!("nothing removed");
1263 } else {
1264 println!("nothing to clean");
1265 }
1266 } else {
1267 for item in removed {
1268 println!("removed {item}");
1269 }
1270 }
1271 Ok(report_writes(&repo))
1272}
1273
1274fn cmd_post(
1276 prs: &[i64],
1277 repo_path: &Path,
1278 config_path: Option<&Path>,
1279 file: Option<&Path>,
1280 dry_run: bool,
1281) -> Result<i32> {
1282 let cfg = config::load(config_path)?;
1283 let repo = Repo::open(repo_path, &cfg)?;
1284
1285 if file.is_some() && prs.len() > 1 {
1286 bail!("--file posts one review, so give it one pull request number");
1287 }
1288
1289 let mut failed = false;
1290 for number in prs {
1291 let text = match file {
1292 Some(path) => std::fs::read_to_string(path)
1293 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1294 None => match repo.read_pending_comment(*number) {
1295 Some(text) => text,
1296 None => {
1297 logging::error(format!(
1298 "no saved review for PR #{number}. `spar review {number} --dry-run` \
1299 produces one, or pass --file."
1300 ));
1301 failed = true;
1302 continue;
1303 }
1304 },
1305 };
1306 if text.trim().is_empty() {
1307 logging::error(format!("the saved review for PR #{number} is empty"));
1308 failed = true;
1309 continue;
1310 }
1311 if dry_run {
1312 println!("\n{}\n", text.trim());
1313 log!("would post the above to PR #{number}");
1314 continue;
1315 }
1316 match repo.comment_pr(*number, &text) {
1319 Ok(()) => log!("posted to PR #{number}"),
1320 Err(e) => {
1321 logging::error(format!("could not post to PR #{number}: {e}"));
1322 failed = true;
1323 }
1324 }
1325 }
1326 Ok(i32::from(failed).max(report_writes(&repo)))
1327}
1328
1329fn cmd_init_update(out: &Path) -> Result<i32> {
1335 let text = std::fs::read_to_string(out)
1336 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1337 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1340
1341 let unset = config::unmentioned_options(&text);
1342 if unset.is_empty() {
1343 println!("{} already mentions every setting.", out.display());
1344 return Ok(0);
1345 }
1346
1347 let mut block = String::new();
1348 if !text.ends_with('\n') {
1349 block.push('\n');
1350 }
1351 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1352 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1353 let mut section = "";
1354 for option in &unset {
1355 if option.section != section {
1356 section = option.section;
1357 block.push_str(&format!("\n# [{section}]\n"));
1358 }
1359 block.push('\n');
1364 block.push_str(&wrap_comment(note_for(&option.key)));
1365 block.push_str(&format!("# {} = {}\n", option.key, option.default));
1366 }
1367
1368 use std::io::Write;
1369 std::fs::OpenOptions::new()
1370 .append(true)
1371 .open(out)
1372 .and_then(|mut f| f.write_all(block.as_bytes()))
1373 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1374
1375 println!(
1376 "added {} setting(s) to {} as comments",
1377 unset.len(),
1378 out.display()
1379 );
1380 Ok(0)
1381}
1382
1383fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1384 if out.exists() && !force {
1385 logging::error(format!(
1386 "{} already exists. `--update` appends any settings it does not mention, \
1387 `--force` overwrites it.",
1388 out.display()
1389 ));
1390 return Ok(1);
1391 }
1392
1393 let presets = config::available_presets();
1394 if presets.is_empty() {
1395 bail!("no presets available, which should be impossible in a released build");
1396 }
1397
1398 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1399 for name in &presets {
1400 let raw = config::load_preset(name)?;
1401 let mut spec: config::AgentSpec = match raw
1405 .as_table()
1406 .cloned()
1407 .ok_or_else(|| spar_err!("not a table"))
1408 .and_then(|t| {
1409 toml::Value::Table(t)
1410 .try_into()
1411 .map_err(|e| spar_err!("{e}"))
1412 }) {
1413 Ok(spec) => spec,
1414 Err(e) => {
1415 println!(" BROKEN {name:10} {}", e.first_line());
1416 continue;
1417 }
1418 };
1419 spec.name = name.clone();
1420 match Agent::new(spec.clone()).resolve_bin() {
1421 Ok(path) => {
1422 println!(" found {name:10} {}", path.display());
1423 found.push((name.clone(), path.to_path_buf(), spec));
1424 }
1425 Err(_) => println!(" missing {name}"),
1426 }
1427 }
1428
1429 if found.len() < 2 {
1430 logging::error(format!(
1431 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1432 presets as a reference.",
1433 found.len(),
1434 out.display()
1435 ));
1436 return Ok(1);
1437 }
1438
1439 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1441 if found.len() > 2 {
1442 log!(
1443 "{} agents available, picking {} and {}. Edit {} to change.",
1444 found.len(),
1445 chosen[0].0,
1446 chosen[1].0,
1447 out.display()
1448 );
1449 }
1450
1451 let mut text = String::from(
1452 "# Generated by `spar init`. Each agent inherits a command template from a\n\
1453 # built in preset; anything set here overrides it.\n\
1454 #\n\
1455 # Commented lines are the other options, each with a working value.\n\
1456 # Uncomment one to change it.\n\n",
1457 );
1458 for (name, _, spec) in &chosen {
1459 text.push_str(&agent_block(name, spec));
1460 }
1461 text.push_str(&settings_block(&chosen[0].0));
1462
1463 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1464 println!("\nwrote {}", out.display());
1465 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1466 Ok(0)
1467}
1468
1469fn report_fallback(agent: &Agent) {
1475 let Some(backup) = agent.fallback() else {
1476 return;
1477 };
1478 match backup.resolve_bin() {
1479 Ok(bin) => println!(
1480 " fallback {} ({})",
1481 bin.display(),
1482 backup.spec.describe()
1483 ),
1484 Err(_) => println!(
1485 " fallback {} not found, so it will not stand in. Set {} to its path.",
1486 backup.program(),
1487 backup.env_key()
1488 ),
1489 }
1490}
1491
1492type Setting = (bool, &'static str, &'static str);
1503
1504const LOOP_OPTIONS: &[Setting] = &[
1505 (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."),
1506 (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."),
1507 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1508 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1509 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1510 (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."),
1511 (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."),
1512 (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."),
1513 (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."),
1514 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1515 (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."),
1516 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1517 (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."),
1518 (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."),
1519 (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."),
1520 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1521 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1522 (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."),
1523 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1524 (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."),
1525 (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."),
1526 (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."),
1527 (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."),
1528 (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."),
1529 (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."),
1530 (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."),
1531];
1532
1533const STYLE_OPTIONS: &[Setting] = &[
1534 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1535 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1536 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1537 (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."),
1538 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1539 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1540 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1541 (true, "max_body_chars", "A pull request body."),
1542 (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."),
1543];
1544
1545fn settings_block(first_implementor: &str) -> String {
1550 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1551 .into_iter()
1552 .map(|option| (option.key, option.default))
1553 .collect();
1554 let value = |key: &str| match key {
1557 "first_implementor" => format!("\"{first_implementor}\""),
1558 other => defaults.get(other).cloned().unwrap_or_default(),
1559 };
1560
1561 let mut out = String::from("[loop]\n");
1562 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1563 out.push_str(concat!(
1564 "\n[loop.effort_schedule]\n",
1565 "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1566 "# these are examples rather than defaults. Left out, each agent uses\n",
1567 "# the effort its own block asked for.\n",
1568 "# round_1 = \"high\" # the deep first review\n",
1569 "# rest = \"low\" # later rounds and the closing pass\n\n",
1570 ));
1571 out.push_str("[style]\n");
1572 out.push_str(&option_lines(STYLE_OPTIONS, &value));
1573 out
1574}
1575
1576fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1580 let mut out = String::new();
1581 for (commented, key, note) in options {
1582 if !out.is_empty() {
1583 out.push('\n');
1584 }
1585 out.push_str(&wrap_comment(note));
1586 let lead = if *commented { "# " } else { "" };
1587 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
1588 }
1589 out
1590}
1591
1592type Probe = Box<dyn Fn() -> Result<String>>;
1595
1596fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1603 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1604
1605 if let Some(extra) = &spec.options_note {
1617 if !spec.models.is_empty() || !spec.efforts.is_empty() {
1618 out.push('\n');
1619 out.push_str(&wrap_comment(extra));
1620 }
1621 }
1622 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
1623 let Some(suggested) = choices.first() else {
1624 continue;
1625 };
1626 let mut note = format!("Omit {key} to use the CLI's own default.");
1627 if choices.len() > 1 {
1628 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
1629 }
1630 out.push('\n');
1631 out.push_str(&wrap_comment(¬e));
1632 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
1633 }
1634
1635 out.push('\n');
1639 out.push_str(&wrap_comment(
1640 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
1641 never retried, so err long.",
1642 ));
1643 out.push_str(&format!("# timeout = {}\n", spec.timeout));
1644
1645 let backup = if name == "cursor" { "gemini" } else { "cursor" };
1648 out.push('\n');
1649 out.push_str(&wrap_comment(
1650 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
1651 of this agent, never alongside it.",
1652 ));
1653 out.push_str(&format!(
1654 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1655 ));
1656
1657 out.push('\n');
1662 out.push_str(&wrap_comment(
1663 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
1664 that has no preset.",
1665 ));
1666 out.push('\n');
1667 out
1668}
1669
1670fn note_for(key: &str) -> &'static str {
1676 LOOP_OPTIONS
1677 .iter()
1678 .chain(STYLE_OPTIONS)
1679 .find(|(_, name, _)| *name == key)
1680 .map(|(_, _, note)| *note)
1681 .unwrap_or("")
1682}
1683
1684fn wrap_comment(text: &str) -> String {
1686 const WIDTH: usize = 76;
1687 let mut out = String::new();
1688 let mut line = String::from("#");
1689 for word in text.split_whitespace() {
1690 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1691 out.push_str(&line);
1692 out.push('\n');
1693 line = String::from("#");
1694 }
1695 line.push(' ');
1696 line.push_str(word);
1697 }
1698 if line.len() > 1 {
1699 out.push_str(&line);
1700 out.push('\n');
1701 }
1702 out
1703}
1704
1705fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1706 let mut ok = true;
1707
1708 let probes: Vec<(&str, Probe)> = vec![
1709 (
1710 "git",
1711 Box::new(|| {
1712 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1713 .map(|s| first_line(&s))
1714 }),
1715 ),
1716 (
1717 "gh",
1718 Box::new(|| {
1719 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1720 .map(|s| first_line(&s))
1721 }),
1722 ),
1723 (
1724 "gh auth",
1725 Box::new(|| {
1726 let out = proc::exec(
1727 &["gh".into(), "auth".into(), "status".into()],
1728 &ExecOpts::new().check(false).timeout_secs(60),
1729 )?;
1730 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1731 if out.ok() {
1732 Ok(first_line(&text))
1733 } else {
1734 Err(spar_err!("not authenticated. Run `gh auth login`."))
1735 }
1736 }),
1737 ),
1738 ];
1739
1740 for (label, probe) in probes {
1741 match probe() {
1742 Ok(detail) => println!(" ok {label:12} {detail}"),
1743 Err(e) => {
1744 println!(" FAIL {label:12} {}", e.first_line());
1745 ok = false;
1746 }
1747 }
1748 }
1749
1750 let found = config::find_config(config_path)?;
1751 let Some(path) = found else {
1752 println!("\n no spar.toml found. Run `spar init` to generate one.");
1753 println!(
1754 " presets available: {}",
1755 config::available_presets().join(", ")
1756 );
1757 return Ok(if ok { 0 } else { 1 });
1758 };
1759
1760 println!("\n config: {}", path.display());
1761 let cfg = match config::load(Some(&path)) {
1762 Ok(cfg) => cfg,
1763 Err(e) => {
1764 println!(" FAIL config {e}");
1765 return Ok(1);
1766 }
1767 };
1768
1769 let mut resolved = Vec::new();
1772 for spec in &cfg.agents {
1773 let agent = Agent::new(spec.clone());
1774 match agent.resolve_bin() {
1775 Ok(bin) => {
1776 println!(
1777 " ok {:12} {} ({})",
1778 spec.name,
1779 bin.display(),
1780 spec.describe()
1781 );
1782 report_fallback(&agent);
1783 resolved.push(agent);
1784 }
1785 Err(e) => {
1786 println!(" FAIL {:12} {}", spec.name, e.first_line());
1787 ok = false;
1788 }
1789 }
1790 }
1791
1792 if resolved.len() == cfg.agents.len() {
1793 if let Some(warning) = agent::correlation_warning(&resolved) {
1794 println!("\n WARNING {warning}");
1795 }
1796 }
1797
1798 println!(
1799 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1800 cfg.loop_cfg.max_rounds,
1801 cfg.loop_cfg.auto_merge,
1802 cfg.loop_cfg.worktrees,
1803 cfg.loop_cfg.followups,
1804 cfg.style.terse
1805 );
1806 if let Ok(text) = std::fs::read_to_string(&path) {
1810 let unset = config::unmentioned_options(&text);
1811 if !unset.is_empty() {
1812 println!(
1813 "\n {} setting(s) this config does not mention, all at their defaults:",
1814 unset.len()
1815 );
1816 for option in &unset {
1817 println!(
1818 " [{}] {} = {}",
1819 option.section, option.key, option.default
1820 );
1821 }
1822 println!(
1823 " `spar init --update {}` appends them as comments.",
1824 path.display()
1825 );
1826 }
1827 }
1828
1829 println!(
1830 "{}",
1831 if ok {
1832 "\nready"
1833 } else {
1834 "\nmissing prerequisites"
1835 }
1836 );
1837 Ok(if ok { 0 } else { 1 })
1838}
1839
1840fn first_line(text: &str) -> String {
1841 text.trim().lines().next().unwrap_or("").trim().to_string()
1842}
1843
1844fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
1849 println!("\n{}", "=".repeat(60));
1850 for r in results {
1851 println!(
1852 "#{:<5} {:<10} rounds={} {}",
1853 r.issue,
1854 r.status.to_string(),
1855 r.rounds,
1856 r.pr.as_deref().unwrap_or("")
1857 );
1858 for note in &r.notes {
1859 println!(" {}", first_line(note));
1860 }
1861 for url in &r.filed {
1862 println!(" filed {url}");
1863 }
1864 for dispute in &r.disputes {
1865 println!(
1866 " disputed: {}",
1867 report_item(&dispute.title, &dispute.file)
1868 );
1869 }
1870 for finding in &r.noted {
1871 println!(
1872 " noted, not blocking: {}",
1873 report_item(&finding.title, &finding.file)
1874 );
1875 }
1876 }
1877 println!("{}", "=".repeat(60));
1878
1879 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1880 println!("\nApproved PRs are waiting on you to merge.");
1881 }
1882 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1883 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1884 println!(
1885 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1886 Set followups = \"issues\" to file them."
1887 );
1888 }
1889 let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded));
1890 issue_exit.max(report_writes(repo))
1891}
1892
1893fn report_writes(repo: &Repo) -> i32 {
1898 let writes = repo.write_summary();
1899 if let Some(line) = write_summary_line(writes) {
1900 println!("\n{line}");
1901 }
1902 write_exit_code(writes)
1903}
1904
1905fn write_summary_line(writes: WriteSummary) -> Option<String> {
1906 (writes.attempted > 0).then(|| {
1907 format!(
1908 "writes: {} attempted, {} succeeded, {} failed",
1909 writes.attempted,
1910 writes.succeeded(),
1911 writes.failed
1912 )
1913 })
1914}
1915
1916fn write_exit_code(writes: WriteSummary) -> i32 {
1917 i32::from(writes.failed > 0)
1918}
1919
1920fn report_item(title: &str, file: &str) -> String {
1921 match file.trim() {
1922 "" => title.to_string(),
1923 location => format!("{title} ({location})"),
1924 }
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929 use super::*;
1930 use clap::CommandFactory;
1931
1932 #[test]
1933 fn report_items_include_their_location() {
1934 assert_eq!(
1935 "Same title (src/a.rs:10)",
1936 report_item("Same title", "src/a.rs:10")
1937 );
1938 assert_eq!("General point", report_item("General point", ""));
1939 }
1940
1941 #[test]
1942 fn every_attempted_write_failing_is_a_failed_run() {
1943 let writes = WriteSummary {
1944 attempted: 3,
1945 failed: 3,
1946 };
1947
1948 assert_eq!(1, write_exit_code(writes));
1949 assert_eq!(
1950 Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
1951 write_summary_line(writes)
1952 );
1953 }
1954
1955 #[test]
1956 fn a_partial_write_failure_is_reported_after_the_run() {
1957 let writes = WriteSummary {
1958 attempted: 3,
1959 failed: 1,
1960 };
1961
1962 assert_eq!(1, write_exit_code(writes));
1963 assert_eq!(2, writes.succeeded());
1964 }
1965
1966 #[test]
1967 fn the_parser_is_internally_consistent() {
1968 Cli::command().debug_assert();
1969 }
1970
1971 #[test]
1972 fn quiet_is_accepted_before_or_after_the_subcommand() {
1973 for argv in [
1974 vec!["spar", "--quiet", "run", "42"],
1975 vec!["spar", "run", "42", "--quiet"],
1976 vec!["spar", "resume", "--quiet"],
1977 vec!["spar", "init", "-q"],
1978 ] {
1979 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1980 }
1981 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1982 }
1983
1984 #[test]
1985 fn several_issue_numbers_are_accepted() {
1986 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1987 match cli.command {
1988 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1989 other => panic!("{other:?}"),
1990 }
1991 }
1992
1993 #[test]
1994 fn issue_numbers_and_flags_can_be_interleaved() {
1995 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1996 match cli.command {
1997 Command::Run {
1998 issues, loop_flags, ..
1999 } => {
2000 assert_eq!(vec![42, 51], issues);
2001 assert!(loop_flags.auto_merge);
2002 }
2003 other => panic!("{other:?}"),
2004 }
2005 }
2006
2007 #[test]
2008 fn every_command_that_reads_a_config_accepts_one() {
2009 for argv in [
2010 vec!["spar", "run", "42"],
2011 vec!["spar", "triage"],
2012 vec!["spar", "resume"],
2013 vec!["spar", "followup"],
2014 vec!["spar", "checkin"],
2015 vec!["spar", "split"],
2016 vec!["spar", "clean"],
2017 vec!["spar", "doctor"],
2018 ] {
2019 let mut full = argv.clone();
2020 full.extend(["--config", "other.toml"]);
2021 let cli = Cli::parse_from(&full);
2022 let config = match cli.command {
2023 Command::Run { common, .. }
2024 | Command::Triage { common, .. }
2025 | Command::Resume { common, .. }
2026 | Command::Followup { common, .. }
2027 | Command::Split { common, .. }
2028 | Command::Checkin { common, .. } => common.config,
2029 Command::Clean { config, .. } | Command::Doctor { config } => config,
2030 other => panic!("{other:?}"),
2031 };
2032 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2033 }
2034 }
2035
2036 #[test]
2037 fn auto_merge_is_off_unless_asked_for() {
2038 let cli = Cli::parse_from(["spar", "run"]);
2039 match cli.command {
2040 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2041 other => panic!("{other:?}"),
2042 }
2043 }
2044
2045 #[test]
2048 fn every_command_that_reads_a_config_takes_instructions() {
2049 for argv in [
2052 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2053 vec![
2054 "spar",
2055 "triage",
2056 "7",
2057 "--instructions",
2058 "Do not wait for CI.",
2059 ],
2060 vec![
2061 "spar",
2062 "resume",
2063 "7",
2064 "--instructions",
2065 "Do not wait for CI.",
2066 ],
2067 vec![
2068 "spar",
2069 "review",
2070 "7",
2071 "--instructions",
2072 "Do not wait for CI.",
2073 ],
2074 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2075 vec![
2076 "spar",
2077 "checkin",
2078 "7",
2079 "--instructions",
2080 "Do not wait for CI.",
2081 ],
2082 vec![
2083 "spar",
2084 "split",
2085 "7",
2086 "--instructions",
2087 "Do not wait for CI.",
2088 ],
2089 ] {
2090 let parsed = Cli::parse_from(&argv);
2091 let common = match parsed.command {
2092 Command::Run { common, .. }
2093 | Command::Triage { common, .. }
2094 | Command::Resume { common, .. }
2095 | Command::Review { common, .. }
2096 | Command::Followup { common, .. }
2097 | Command::Split { common, .. }
2098 | Command::Checkin { common, .. } => common,
2099 other => panic!("{other:?}"),
2100 };
2101 assert_eq!(
2102 Some("Do not wait for CI."),
2103 common.instructions.as_deref(),
2104 "{argv:?}"
2105 );
2106 }
2107 }
2108
2109 #[test]
2110 fn the_two_close_skipped_flags_are_mutually_exclusive() {
2111 assert!(
2112 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2113 );
2114 }
2115
2116 #[test]
2119 fn close_skipped_is_offered_only_where_it_means_something() {
2120 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2121 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2122 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2124 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2125 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2126 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2127 }
2128
2129 #[test]
2132 fn followup_takes_no_numbers() {
2133 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
2134 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
2135 }
2136
2137 #[test]
2140 fn the_two_stopping_points_are_mutually_exclusive() {
2141 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
2142 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
2143 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
2144 }
2145
2146 #[test]
2147 fn the_close_skipped_pair_resolves_to_a_tristate() {
2148 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
2149 Command::Run { triage_flags, .. } => {
2150 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
2151 (true, _) => Some(true),
2152 (_, true) => Some(false),
2153 _ => None,
2154 }
2155 }
2156 other => panic!("{other:?}"),
2157 };
2158 assert_eq!(None, read(&["spar", "run"]));
2159 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
2160 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
2161 }
2162
2163 #[test]
2164 fn the_default_limit_is_twenty() {
2165 let cli = Cli::parse_from(["spar", "run"]);
2166 match cli.command {
2167 Command::Run { common, .. } => assert_eq!(20, common.limit),
2168 other => panic!("{other:?}"),
2169 }
2170 }
2171
2172 #[test]
2173 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
2174 assert!(matches!(
2175 Cli::parse_from(["spar", "scrub-filter"]).command,
2176 Command::ScrubFilter
2177 ));
2178 let help = Cli::command().render_long_help().to_string();
2179 assert!(
2180 !help.contains("scrub-filter"),
2181 "it is plumbing, not a command"
2182 );
2183 }
2184
2185 #[test]
2186 fn review_takes_pr_numbers_and_a_dry_run() {
2187 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
2188 match cli.command {
2189 Command::Review { items, dry_run, .. } => {
2190 assert_eq!(vec![101, 102], items);
2191 assert!(dry_run);
2192 }
2193 other => panic!("{other:?}"),
2194 }
2195 }
2196
2197 #[test]
2198 fn review_posts_unless_told_not_to() {
2199 match Cli::parse_from(["spar", "review", "101"]).command {
2200 Command::Review { dry_run, .. } => assert!(!dry_run),
2201 other => panic!("{other:?}"),
2202 }
2203 }
2204
2205 #[test]
2206 fn review_with_no_numbers_is_allowed() {
2207 match Cli::parse_from(["spar", "review"]).command {
2208 Command::Review { items, .. } => assert!(items.is_empty()),
2209 other => panic!("{other:?}"),
2210 }
2211 }
2212
2213 #[test]
2214 fn review_takes_its_own_round_budget() {
2215 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
2216 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
2217 other => panic!("{other:?}"),
2218 }
2219 }
2220
2221 #[test]
2222 fn checkin_takes_pr_numbers_and_a_dry_run() {
2223 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
2224 Command::Checkin { items, dry_run, .. } => {
2225 assert_eq!(vec![108, 112], items);
2226 assert!(dry_run);
2227 }
2228 other => panic!("{other:?}"),
2229 }
2230 match Cli::parse_from(["spar", "checkin"]).command {
2231 Command::Checkin { items, dry_run, .. } => {
2232 assert!(items.is_empty());
2233 assert!(!dry_run);
2234 }
2235 other => panic!("{other:?}"),
2236 }
2237 }
2238
2239 #[test]
2245 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
2246 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
2247 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
2248 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
2249 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
2250 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
2252 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
2253 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
2254 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
2255 }
2256
2257 #[test]
2258 fn resume_takes_a_next_override() {
2259 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
2260 match cli.command {
2261 Command::Resume {
2262 prs, next_actor, ..
2263 } => {
2264 assert_eq!(vec![108], prs);
2265 assert_eq!(Some("codex".to_string()), next_actor);
2266 }
2267 other => panic!("{other:?}"),
2268 }
2269 }
2270}
2271
2272#[cfg(test)]
2273mod absorb_tests {
2274 use super::*;
2275
2276 #[test]
2277 fn absorb_is_off_unless_asked_for() {
2278 match Cli::parse_from(["spar", "run"]).command {
2279 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
2280 other => panic!("{other:?}"),
2281 }
2282 }
2283
2284 #[test]
2285 fn absorb_takes_a_wave_count() {
2286 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
2287 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
2288 other => panic!("{other:?}"),
2289 }
2290 }
2291
2292 #[test]
2293 fn absorb_is_only_offered_where_issues_are_worked() {
2294 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
2295 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
2296 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
2297 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
2300 }
2301}
2302
2303#[cfg(test)]
2304mod split_tests {
2305 use super::*;
2306
2307 #[test]
2308 fn split_takes_numbers_of_either_kind() {
2309 match Cli::parse_from(["spar", "split", "10", "12"]).command {
2310 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
2311 other => panic!("{other:?}"),
2312 }
2313 }
2314
2315 #[test]
2318 fn split_with_no_numbers_is_allowed() {
2319 match Cli::parse_from(["spar", "split"]).command {
2320 Command::Split {
2321 items,
2322 dry_run,
2323 again,
2324 ..
2325 } => {
2326 assert!(items.is_empty());
2327 assert!(!dry_run);
2328 assert!(!again);
2329 }
2330 other => panic!("{other:?}"),
2331 }
2332 }
2333
2334 #[test]
2338 fn split_can_be_told_to_write_nothing() {
2339 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
2340 Command::Split { dry_run, .. } => assert!(dry_run),
2341 other => panic!("{other:?}"),
2342 }
2343 }
2344
2345 #[test]
2348 fn split_can_be_told_to_split_something_it_already_split() {
2349 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
2350 Command::Split { again, .. } => assert!(again),
2351 other => panic!("{other:?}"),
2352 }
2353 }
2354
2355 #[test]
2358 fn split_takes_the_flags_that_govern_picking() {
2359 match Cli::parse_from([
2360 "spar",
2361 "split",
2362 "--limit",
2363 "5",
2364 "--min-number",
2365 "480",
2366 "--config",
2367 "other.toml",
2368 ])
2369 .command
2370 {
2371 Command::Split { common, .. } => {
2372 assert_eq!(5, common.limit);
2373 assert_eq!(Some(480), common.min_number);
2374 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
2375 }
2376 other => panic!("{other:?}"),
2377 }
2378 }
2379
2380 #[test]
2383 fn split_offers_no_flag_that_would_make_it_a_second_run() {
2384 for flag in [
2385 vec!["--auto-merge"],
2386 vec!["--max-rounds", "2"],
2387 vec!["--close-skipped"],
2388 vec!["--no-close-skipped"],
2389 vec!["--keep-worktrees"],
2390 ] {
2391 let mut argv = vec!["spar", "split", "10"];
2392 argv.extend(flag.iter().copied());
2393 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
2394 }
2395 }
2396}
2397
2398#[cfg(test)]
2399mod min_number_tests {
2400 use super::*;
2401
2402 fn read(argv: &[&str]) -> Option<i64> {
2403 match Cli::parse_from(argv).command {
2404 Command::Run { common, .. }
2405 | Command::Triage { common, .. }
2406 | Command::Resume { common, .. }
2407 | Command::Review { common, .. }
2408 | Command::Followup { common, .. }
2409 | Command::Split { common, .. }
2410 | Command::Checkin { common, .. } => common.min_number,
2411 other => panic!("{other:?}"),
2412 }
2413 }
2414
2415 #[test]
2416 fn there_is_no_floor_unless_one_is_asked_for() {
2417 assert_eq!(None, read(&["spar", "run"]));
2418 }
2419
2420 #[test]
2421 fn every_command_that_picks_for_itself_accepts_a_floor() {
2422 for cmd in ["run", "triage", "resume", "review", "checkin", "split"] {
2423 assert_eq!(
2424 Some(480),
2425 read(&["spar", cmd, "--min-number", "480"]),
2426 "{cmd}"
2427 );
2428 }
2429 }
2430}
2431
2432#[cfg(test)]
2433mod settings_block_tests {
2434 use super::*;
2435
2436 fn written(line: &str) -> String {
2439 let after = line.split_once('=').expect("an assignment").1;
2440 let mut quoted = false;
2441 for (i, c) in after.char_indices() {
2442 match c {
2443 '"' => quoted = !quoted,
2444 '#' if !quoted => return after[..i].trim().to_string(),
2445 _ => {}
2446 }
2447 }
2448 after.trim().to_string()
2449 }
2450
2451 fn line_for(text: &str, key: &str) -> String {
2452 text.lines()
2453 .find(|l| {
2454 let bare = l.trim_start().trim_start_matches('#').trim_start();
2455 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
2456 })
2457 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
2458 .to_string()
2459 }
2460
2461 #[test]
2468 fn every_value_it_offers_is_the_default_it_actually_has() {
2469 let text = settings_block("claude");
2470 for option in config::known_options() {
2471 if option.section == "loop.effort_schedule" {
2475 continue;
2476 }
2477 let line = line_for(&text, &option.key);
2478 assert_eq!(
2479 option.default,
2480 written(&line),
2481 "the generated config offers `{}`, but the default is {}",
2482 line.trim(),
2483 option.default
2484 );
2485 }
2486 }
2487
2488 #[test]
2492 fn it_offers_every_option_the_parser_knows_about() {
2493 let text = settings_block("claude");
2494 let missing: Vec<String> = config::unmentioned_options(&text)
2495 .into_iter()
2496 .map(|o| format!("[{}] {}", o.section, o.key))
2497 .collect();
2498 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
2499 }
2500
2501 #[test]
2505 fn every_option_it_offers_can_be_uncommented_and_still_load() {
2506 let mut text = String::from(
2507 "[agents.claude]\ncommand = [\"claude\"]\n\n\
2508 [agents.codex]\ncommand = [\"codex\"]\n\n",
2509 );
2510 for line in settings_block("claude").lines() {
2511 text.push_str(uncomment(line).unwrap_or(line));
2512 text.push('\n');
2513 }
2514 let cfg = config::parse(&text).expect("a config of its own suggestions");
2515 assert_eq!("claude", cfg.first_implementor);
2516 }
2517
2518 fn uncomment(line: &str) -> Option<&str> {
2521 let bare = line.trim_start().strip_prefix('#')?.trim_start();
2522 let key = bare.split_once('=')?.0.trim();
2525 let named = !key.is_empty()
2526 && key
2527 .chars()
2528 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
2529 named.then_some(bare)
2530 }
2531
2532 #[test]
2533 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
2534 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
2535 }
2536
2537 #[test]
2542 fn a_note_sits_above_the_option_it_describes() {
2543 let text = settings_block("claude");
2544 assert!(
2545 text.lines().all(|l| l.chars().count() <= 80),
2546 "a line runs off the edge:\n{text}"
2547 );
2548 assert!(
2549 text.lines().all(|l| !l.starts_with(' ')),
2550 "a line is indented, so the columns are back:\n{text}"
2551 );
2552
2553 let lines: Vec<&str> = text.lines().collect();
2555 let at = lines
2556 .iter()
2557 .position(|l| l.starts_with("max_rounds"))
2558 .expect("max_rounds");
2559 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
2560 assert!(
2561 lines[at - 1].contains("lifetime cap"),
2562 "the note above it is the end of its own note: {:?}",
2563 lines[at - 1]
2564 );
2565 }
2566
2567 #[test]
2569 fn every_note_starts_as_a_sentence() {
2570 for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
2571 let first = note.chars().next().expect("a note");
2572 assert!(
2573 first.is_uppercase(),
2574 "{key} reads as a margin scribble rather than a sentence: {note}"
2575 );
2576 }
2577 }
2578}
2579
2580#[cfg(test)]
2581mod agent_block_tests {
2582 use super::*;
2583
2584 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
2585 let mut spec: config::AgentSpec =
2586 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
2587 .try_into()
2588 .expect("builds");
2589 spec.models = models.iter().map(|s| s.to_string()).collect();
2590 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
2591 spec
2592 }
2593
2594 #[test]
2598 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
2599 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
2600 assert!(!block.contains("..."), "{block}");
2601 assert!(!block.contains("effort"), "{block}");
2602 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
2603 }
2604
2605 #[test]
2608 fn only_the_options_that_follow_are_introduced() {
2609 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
2610 assert!(model_only.contains("Omit model to use"), "{model_only}");
2611 assert!(!model_only.contains("Omit effort"), "{model_only}");
2612
2613 let both = agent_block("claude", &spec(&["fable"], &["high"]));
2614 assert!(both.contains("Omit model to use"), "{both}");
2615 assert!(both.contains("Omit effort to use"), "{both}");
2616 }
2617
2618 #[test]
2621 fn a_preset_with_no_hints_still_writes_a_usable_block() {
2622 let block = agent_block("gemini", &spec(&[], &[]));
2623 assert!(!block.contains("..."), "{block}");
2624 assert!(!block.contains("Omit"), "{block}");
2625 assert!(
2626 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
2627 "{block}"
2628 );
2629 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
2631 assert!(block.contains("# timeout = "), "{block}");
2632 }
2633
2634 #[test]
2637 fn the_timeout_offered_is_the_one_the_agent_would_use() {
2638 let mut spec = spec(&["a"], &[]);
2639 spec.timeout = 7200;
2640 assert!(
2641 agent_block("custom", &spec).contains("# timeout = 7200"),
2642 "the generator kept its own copy"
2643 );
2644 }
2645
2646 #[test]
2648 fn alternatives_are_listed_only_when_there_are_any() {
2649 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
2650 let single = agent_block("b", &spec(&["only"], &[]));
2651 assert!(!single.contains("One of:"), "{single}");
2652 }
2653
2654 #[test]
2658 fn the_presets_note_is_said_once() {
2659 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
2660 spec.options_note = Some("Check the current sets with: mytool --help".into());
2661 let block = agent_block("mytool", &spec);
2662 assert_eq!(
2663 1,
2664 block.matches("Check the current sets").count(),
2665 "{block}"
2666 );
2667 }
2668}