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;
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))
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))
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))
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(0);
616 }
617 Ok(report(&results, &cfg))
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(outcome.exit_code());
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(outcome.exit_code());
648 }
649 Ok(report(&results, &cfg).max(outcome.exit_code()))
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))
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 println!("nothing to clean");
1262 } else {
1263 for item in removed {
1264 println!("removed {item}");
1265 }
1266 }
1267 Ok(0)
1268}
1269
1270fn cmd_post(
1272 prs: &[i64],
1273 repo_path: &Path,
1274 config_path: Option<&Path>,
1275 file: Option<&Path>,
1276 dry_run: bool,
1277) -> Result<i32> {
1278 let cfg = config::load(config_path)?;
1279 let repo = Repo::open(repo_path, &cfg)?;
1280
1281 if file.is_some() && prs.len() > 1 {
1282 bail!("--file posts one review, so give it one pull request number");
1283 }
1284
1285 let mut failed = false;
1286 for number in prs {
1287 let text = match file {
1288 Some(path) => std::fs::read_to_string(path)
1289 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1290 None => match repo.read_pending_comment(*number) {
1291 Some(text) => text,
1292 None => {
1293 logging::error(format!(
1294 "no saved review for PR #{number}. `spar review {number} --dry-run` \
1295 produces one, or pass --file."
1296 ));
1297 failed = true;
1298 continue;
1299 }
1300 },
1301 };
1302 if text.trim().is_empty() {
1303 logging::error(format!("the saved review for PR #{number} is empty"));
1304 failed = true;
1305 continue;
1306 }
1307 if dry_run {
1308 println!("\n{}\n", text.trim());
1309 log!("would post the above to PR #{number}");
1310 continue;
1311 }
1312 match repo.comment_pr(*number, &text) {
1315 Ok(()) => log!("posted to PR #{number}"),
1316 Err(e) => {
1317 logging::error(format!("could not post to PR #{number}: {e}"));
1318 failed = true;
1319 }
1320 }
1321 }
1322 Ok(if failed { 1 } else { 0 })
1323}
1324
1325fn cmd_init_update(out: &Path) -> Result<i32> {
1331 let text = std::fs::read_to_string(out)
1332 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1333 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1336
1337 let unset = config::unmentioned_options(&text);
1338 if unset.is_empty() {
1339 println!("{} already mentions every setting.", out.display());
1340 return Ok(0);
1341 }
1342
1343 let mut block = String::new();
1344 if !text.ends_with('\n') {
1345 block.push('\n');
1346 }
1347 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1348 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1349 let mut section = "";
1350 for option in &unset {
1351 if option.section != section {
1352 section = option.section;
1353 block.push_str(&format!("\n# [{section}]\n"));
1354 }
1355 block.push('\n');
1360 block.push_str(&wrap_comment(note_for(&option.key)));
1361 block.push_str(&format!("# {} = {}\n", option.key, option.default));
1362 }
1363
1364 use std::io::Write;
1365 std::fs::OpenOptions::new()
1366 .append(true)
1367 .open(out)
1368 .and_then(|mut f| f.write_all(block.as_bytes()))
1369 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1370
1371 println!(
1372 "added {} setting(s) to {} as comments",
1373 unset.len(),
1374 out.display()
1375 );
1376 Ok(0)
1377}
1378
1379fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1380 if out.exists() && !force {
1381 logging::error(format!(
1382 "{} already exists. `--update` appends any settings it does not mention, \
1383 `--force` overwrites it.",
1384 out.display()
1385 ));
1386 return Ok(1);
1387 }
1388
1389 let presets = config::available_presets();
1390 if presets.is_empty() {
1391 bail!("no presets available, which should be impossible in a released build");
1392 }
1393
1394 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1395 for name in &presets {
1396 let raw = config::load_preset(name)?;
1397 let mut spec: config::AgentSpec = match raw
1401 .as_table()
1402 .cloned()
1403 .ok_or_else(|| spar_err!("not a table"))
1404 .and_then(|t| {
1405 toml::Value::Table(t)
1406 .try_into()
1407 .map_err(|e| spar_err!("{e}"))
1408 }) {
1409 Ok(spec) => spec,
1410 Err(e) => {
1411 println!(" BROKEN {name:10} {}", e.first_line());
1412 continue;
1413 }
1414 };
1415 spec.name = name.clone();
1416 match Agent::new(spec.clone()).resolve_bin() {
1417 Ok(path) => {
1418 println!(" found {name:10} {}", path.display());
1419 found.push((name.clone(), path.to_path_buf(), spec));
1420 }
1421 Err(_) => println!(" missing {name}"),
1422 }
1423 }
1424
1425 if found.len() < 2 {
1426 logging::error(format!(
1427 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1428 presets as a reference.",
1429 found.len(),
1430 out.display()
1431 ));
1432 return Ok(1);
1433 }
1434
1435 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1437 if found.len() > 2 {
1438 log!(
1439 "{} agents available, picking {} and {}. Edit {} to change.",
1440 found.len(),
1441 chosen[0].0,
1442 chosen[1].0,
1443 out.display()
1444 );
1445 }
1446
1447 let mut text = String::from(
1448 "# Generated by `spar init`. Each agent inherits a command template from a\n\
1449 # built in preset; anything set here overrides it.\n\
1450 #\n\
1451 # Commented lines are the other options, each with a working value.\n\
1452 # Uncomment one to change it.\n\n",
1453 );
1454 for (name, _, spec) in &chosen {
1455 text.push_str(&agent_block(name, spec));
1456 }
1457 text.push_str(&settings_block(&chosen[0].0));
1458
1459 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1460 println!("\nwrote {}", out.display());
1461 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1462 Ok(0)
1463}
1464
1465fn report_fallback(agent: &Agent) {
1471 let Some(backup) = agent.fallback() else {
1472 return;
1473 };
1474 match backup.resolve_bin() {
1475 Ok(bin) => println!(
1476 " fallback {} ({})",
1477 bin.display(),
1478 backup.spec.describe()
1479 ),
1480 Err(_) => println!(
1481 " fallback {} not found, so it will not stand in. Set {} to its path.",
1482 backup.program(),
1483 backup.env_key()
1484 ),
1485 }
1486}
1487
1488type Setting = (bool, &'static str, &'static str);
1499
1500const LOOP_OPTIONS: &[Setting] = &[
1501 (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."),
1502 (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."),
1503 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1504 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1505 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1506 (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."),
1507 (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."),
1508 (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."),
1509 (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."),
1510 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1511 (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."),
1512 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1513 (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."),
1514 (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."),
1515 (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."),
1516 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1517 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1518 (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."),
1519 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1520 (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."),
1521 (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."),
1522 (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."),
1523 (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."),
1524 (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."),
1525 (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."),
1526 (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."),
1527];
1528
1529const STYLE_OPTIONS: &[Setting] = &[
1530 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1531 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1532 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1533 (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."),
1534 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1535 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1536 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1537 (true, "max_body_chars", "A pull request body."),
1538 (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."),
1539];
1540
1541fn settings_block(first_implementor: &str) -> String {
1546 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1547 .into_iter()
1548 .map(|option| (option.key, option.default))
1549 .collect();
1550 let value = |key: &str| match key {
1553 "first_implementor" => format!("\"{first_implementor}\""),
1554 other => defaults.get(other).cloned().unwrap_or_default(),
1555 };
1556
1557 let mut out = String::from("[loop]\n");
1558 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1559 out.push_str(concat!(
1560 "\n[loop.effort_schedule]\n",
1561 "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1562 "# these are examples rather than defaults. Left out, each agent uses\n",
1563 "# the effort its own block asked for.\n",
1564 "# round_1 = \"high\" # the deep first review\n",
1565 "# rest = \"low\" # later rounds and the closing pass\n\n",
1566 ));
1567 out.push_str("[style]\n");
1568 out.push_str(&option_lines(STYLE_OPTIONS, &value));
1569 out
1570}
1571
1572fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1576 let mut out = String::new();
1577 for (commented, key, note) in options {
1578 if !out.is_empty() {
1579 out.push('\n');
1580 }
1581 out.push_str(&wrap_comment(note));
1582 let lead = if *commented { "# " } else { "" };
1583 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
1584 }
1585 out
1586}
1587
1588type Probe = Box<dyn Fn() -> Result<String>>;
1591
1592fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1599 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1600
1601 if let Some(extra) = &spec.options_note {
1613 if !spec.models.is_empty() || !spec.efforts.is_empty() {
1614 out.push('\n');
1615 out.push_str(&wrap_comment(extra));
1616 }
1617 }
1618 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
1619 let Some(suggested) = choices.first() else {
1620 continue;
1621 };
1622 let mut note = format!("Omit {key} to use the CLI's own default.");
1623 if choices.len() > 1 {
1624 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
1625 }
1626 out.push('\n');
1627 out.push_str(&wrap_comment(¬e));
1628 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
1629 }
1630
1631 out.push('\n');
1635 out.push_str(&wrap_comment(
1636 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
1637 never retried, so err long.",
1638 ));
1639 out.push_str(&format!("# timeout = {}\n", spec.timeout));
1640
1641 let backup = if name == "cursor" { "gemini" } else { "cursor" };
1644 out.push('\n');
1645 out.push_str(&wrap_comment(
1646 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
1647 of this agent, never alongside it.",
1648 ));
1649 out.push_str(&format!(
1650 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1651 ));
1652
1653 out.push('\n');
1658 out.push_str(&wrap_comment(
1659 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
1660 that has no preset.",
1661 ));
1662 out.push('\n');
1663 out
1664}
1665
1666fn note_for(key: &str) -> &'static str {
1672 LOOP_OPTIONS
1673 .iter()
1674 .chain(STYLE_OPTIONS)
1675 .find(|(_, name, _)| *name == key)
1676 .map(|(_, _, note)| *note)
1677 .unwrap_or("")
1678}
1679
1680fn wrap_comment(text: &str) -> String {
1682 const WIDTH: usize = 76;
1683 let mut out = String::new();
1684 let mut line = String::from("#");
1685 for word in text.split_whitespace() {
1686 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1687 out.push_str(&line);
1688 out.push('\n');
1689 line = String::from("#");
1690 }
1691 line.push(' ');
1692 line.push_str(word);
1693 }
1694 if line.len() > 1 {
1695 out.push_str(&line);
1696 out.push('\n');
1697 }
1698 out
1699}
1700
1701fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1702 let mut ok = true;
1703
1704 let probes: Vec<(&str, Probe)> = vec![
1705 (
1706 "git",
1707 Box::new(|| {
1708 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1709 .map(|s| first_line(&s))
1710 }),
1711 ),
1712 (
1713 "gh",
1714 Box::new(|| {
1715 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1716 .map(|s| first_line(&s))
1717 }),
1718 ),
1719 (
1720 "gh auth",
1721 Box::new(|| {
1722 let out = proc::exec(
1723 &["gh".into(), "auth".into(), "status".into()],
1724 &ExecOpts::new().check(false).timeout_secs(60),
1725 )?;
1726 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1727 if out.ok() {
1728 Ok(first_line(&text))
1729 } else {
1730 Err(spar_err!("not authenticated. Run `gh auth login`."))
1731 }
1732 }),
1733 ),
1734 ];
1735
1736 for (label, probe) in probes {
1737 match probe() {
1738 Ok(detail) => println!(" ok {label:12} {detail}"),
1739 Err(e) => {
1740 println!(" FAIL {label:12} {}", e.first_line());
1741 ok = false;
1742 }
1743 }
1744 }
1745
1746 let found = config::find_config(config_path)?;
1747 let Some(path) = found else {
1748 println!("\n no spar.toml found. Run `spar init` to generate one.");
1749 println!(
1750 " presets available: {}",
1751 config::available_presets().join(", ")
1752 );
1753 return Ok(if ok { 0 } else { 1 });
1754 };
1755
1756 println!("\n config: {}", path.display());
1757 let cfg = match config::load(Some(&path)) {
1758 Ok(cfg) => cfg,
1759 Err(e) => {
1760 println!(" FAIL config {e}");
1761 return Ok(1);
1762 }
1763 };
1764
1765 let mut resolved = Vec::new();
1768 for spec in &cfg.agents {
1769 let agent = Agent::new(spec.clone());
1770 match agent.resolve_bin() {
1771 Ok(bin) => {
1772 println!(
1773 " ok {:12} {} ({})",
1774 spec.name,
1775 bin.display(),
1776 spec.describe()
1777 );
1778 report_fallback(&agent);
1779 resolved.push(agent);
1780 }
1781 Err(e) => {
1782 println!(" FAIL {:12} {}", spec.name, e.first_line());
1783 ok = false;
1784 }
1785 }
1786 }
1787
1788 if resolved.len() == cfg.agents.len() {
1789 if let Some(warning) = agent::correlation_warning(&resolved) {
1790 println!("\n WARNING {warning}");
1791 }
1792 }
1793
1794 println!(
1795 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1796 cfg.loop_cfg.max_rounds,
1797 cfg.loop_cfg.auto_merge,
1798 cfg.loop_cfg.worktrees,
1799 cfg.loop_cfg.followups,
1800 cfg.style.terse
1801 );
1802 if let Ok(text) = std::fs::read_to_string(&path) {
1806 let unset = config::unmentioned_options(&text);
1807 if !unset.is_empty() {
1808 println!(
1809 "\n {} setting(s) this config does not mention, all at their defaults:",
1810 unset.len()
1811 );
1812 for option in &unset {
1813 println!(
1814 " [{}] {} = {}",
1815 option.section, option.key, option.default
1816 );
1817 }
1818 println!(
1819 " `spar init --update {}` appends them as comments.",
1820 path.display()
1821 );
1822 }
1823 }
1824
1825 println!(
1826 "{}",
1827 if ok {
1828 "\nready"
1829 } else {
1830 "\nmissing prerequisites"
1831 }
1832 );
1833 Ok(if ok { 0 } else { 1 })
1834}
1835
1836fn first_line(text: &str) -> String {
1837 text.trim().lines().next().unwrap_or("").trim().to_string()
1838}
1839
1840fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1845 println!("\n{}", "=".repeat(60));
1846 for r in results {
1847 println!(
1848 "#{:<5} {:<10} rounds={} {}",
1849 r.issue,
1850 r.status.to_string(),
1851 r.rounds,
1852 r.pr.as_deref().unwrap_or("")
1853 );
1854 for note in &r.notes {
1855 println!(" {}", first_line(note));
1856 }
1857 for url in &r.filed {
1858 println!(" filed {url}");
1859 }
1860 for dispute in &r.disputes {
1861 println!(
1862 " disputed: {}",
1863 report_item(&dispute.title, &dispute.file)
1864 );
1865 }
1866 for finding in &r.noted {
1867 println!(
1868 " noted, not blocking: {}",
1869 report_item(&finding.title, &finding.file)
1870 );
1871 }
1872 }
1873 println!("{}", "=".repeat(60));
1874
1875 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1876 println!("\nApproved PRs are waiting on you to merge.");
1877 }
1878 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1879 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1880 println!(
1881 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1882 Set followups = \"issues\" to file them."
1883 );
1884 }
1885 if results.iter().all(IssueRun::succeeded) {
1886 0
1887 } else {
1888 1
1889 }
1890}
1891
1892fn report_item(title: &str, file: &str) -> String {
1893 match file.trim() {
1894 "" => title.to_string(),
1895 location => format!("{title} ({location})"),
1896 }
1897}
1898
1899#[cfg(test)]
1900mod tests {
1901 use super::*;
1902 use clap::CommandFactory;
1903
1904 #[test]
1905 fn report_items_include_their_location() {
1906 assert_eq!(
1907 "Same title (src/a.rs:10)",
1908 report_item("Same title", "src/a.rs:10")
1909 );
1910 assert_eq!("General point", report_item("General point", ""));
1911 }
1912
1913 #[test]
1914 fn the_parser_is_internally_consistent() {
1915 Cli::command().debug_assert();
1916 }
1917
1918 #[test]
1919 fn quiet_is_accepted_before_or_after_the_subcommand() {
1920 for argv in [
1921 vec!["spar", "--quiet", "run", "42"],
1922 vec!["spar", "run", "42", "--quiet"],
1923 vec!["spar", "resume", "--quiet"],
1924 vec!["spar", "init", "-q"],
1925 ] {
1926 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1927 }
1928 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1929 }
1930
1931 #[test]
1932 fn several_issue_numbers_are_accepted() {
1933 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1934 match cli.command {
1935 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1936 other => panic!("{other:?}"),
1937 }
1938 }
1939
1940 #[test]
1941 fn issue_numbers_and_flags_can_be_interleaved() {
1942 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1943 match cli.command {
1944 Command::Run {
1945 issues, loop_flags, ..
1946 } => {
1947 assert_eq!(vec![42, 51], issues);
1948 assert!(loop_flags.auto_merge);
1949 }
1950 other => panic!("{other:?}"),
1951 }
1952 }
1953
1954 #[test]
1955 fn every_command_that_reads_a_config_accepts_one() {
1956 for argv in [
1957 vec!["spar", "run", "42"],
1958 vec!["spar", "triage"],
1959 vec!["spar", "resume"],
1960 vec!["spar", "followup"],
1961 vec!["spar", "checkin"],
1962 vec!["spar", "split"],
1963 vec!["spar", "clean"],
1964 vec!["spar", "doctor"],
1965 ] {
1966 let mut full = argv.clone();
1967 full.extend(["--config", "other.toml"]);
1968 let cli = Cli::parse_from(&full);
1969 let config = match cli.command {
1970 Command::Run { common, .. }
1971 | Command::Triage { common, .. }
1972 | Command::Resume { common, .. }
1973 | Command::Followup { common, .. }
1974 | Command::Split { common, .. }
1975 | Command::Checkin { common, .. } => common.config,
1976 Command::Clean { config, .. } | Command::Doctor { config } => config,
1977 other => panic!("{other:?}"),
1978 };
1979 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1980 }
1981 }
1982
1983 #[test]
1984 fn auto_merge_is_off_unless_asked_for() {
1985 let cli = Cli::parse_from(["spar", "run"]);
1986 match cli.command {
1987 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1988 other => panic!("{other:?}"),
1989 }
1990 }
1991
1992 #[test]
1995 fn every_command_that_reads_a_config_takes_instructions() {
1996 for argv in [
1999 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2000 vec![
2001 "spar",
2002 "triage",
2003 "7",
2004 "--instructions",
2005 "Do not wait for CI.",
2006 ],
2007 vec![
2008 "spar",
2009 "resume",
2010 "7",
2011 "--instructions",
2012 "Do not wait for CI.",
2013 ],
2014 vec![
2015 "spar",
2016 "review",
2017 "7",
2018 "--instructions",
2019 "Do not wait for CI.",
2020 ],
2021 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2022 vec![
2023 "spar",
2024 "checkin",
2025 "7",
2026 "--instructions",
2027 "Do not wait for CI.",
2028 ],
2029 vec![
2030 "spar",
2031 "split",
2032 "7",
2033 "--instructions",
2034 "Do not wait for CI.",
2035 ],
2036 ] {
2037 let parsed = Cli::parse_from(&argv);
2038 let common = match parsed.command {
2039 Command::Run { common, .. }
2040 | Command::Triage { common, .. }
2041 | Command::Resume { common, .. }
2042 | Command::Review { common, .. }
2043 | Command::Followup { common, .. }
2044 | Command::Split { common, .. }
2045 | Command::Checkin { common, .. } => common,
2046 other => panic!("{other:?}"),
2047 };
2048 assert_eq!(
2049 Some("Do not wait for CI."),
2050 common.instructions.as_deref(),
2051 "{argv:?}"
2052 );
2053 }
2054 }
2055
2056 #[test]
2057 fn the_two_close_skipped_flags_are_mutually_exclusive() {
2058 assert!(
2059 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2060 );
2061 }
2062
2063 #[test]
2066 fn close_skipped_is_offered_only_where_it_means_something() {
2067 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2068 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2069 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2071 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2072 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2073 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2074 }
2075
2076 #[test]
2079 fn followup_takes_no_numbers() {
2080 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
2081 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
2082 }
2083
2084 #[test]
2087 fn the_two_stopping_points_are_mutually_exclusive() {
2088 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
2089 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
2090 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
2091 }
2092
2093 #[test]
2094 fn the_close_skipped_pair_resolves_to_a_tristate() {
2095 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
2096 Command::Run { triage_flags, .. } => {
2097 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
2098 (true, _) => Some(true),
2099 (_, true) => Some(false),
2100 _ => None,
2101 }
2102 }
2103 other => panic!("{other:?}"),
2104 };
2105 assert_eq!(None, read(&["spar", "run"]));
2106 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
2107 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
2108 }
2109
2110 #[test]
2111 fn the_default_limit_is_twenty() {
2112 let cli = Cli::parse_from(["spar", "run"]);
2113 match cli.command {
2114 Command::Run { common, .. } => assert_eq!(20, common.limit),
2115 other => panic!("{other:?}"),
2116 }
2117 }
2118
2119 #[test]
2120 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
2121 assert!(matches!(
2122 Cli::parse_from(["spar", "scrub-filter"]).command,
2123 Command::ScrubFilter
2124 ));
2125 let help = Cli::command().render_long_help().to_string();
2126 assert!(
2127 !help.contains("scrub-filter"),
2128 "it is plumbing, not a command"
2129 );
2130 }
2131
2132 #[test]
2133 fn review_takes_pr_numbers_and_a_dry_run() {
2134 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
2135 match cli.command {
2136 Command::Review { items, dry_run, .. } => {
2137 assert_eq!(vec![101, 102], items);
2138 assert!(dry_run);
2139 }
2140 other => panic!("{other:?}"),
2141 }
2142 }
2143
2144 #[test]
2145 fn review_posts_unless_told_not_to() {
2146 match Cli::parse_from(["spar", "review", "101"]).command {
2147 Command::Review { dry_run, .. } => assert!(!dry_run),
2148 other => panic!("{other:?}"),
2149 }
2150 }
2151
2152 #[test]
2153 fn review_with_no_numbers_is_allowed() {
2154 match Cli::parse_from(["spar", "review"]).command {
2155 Command::Review { items, .. } => assert!(items.is_empty()),
2156 other => panic!("{other:?}"),
2157 }
2158 }
2159
2160 #[test]
2161 fn review_takes_its_own_round_budget() {
2162 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
2163 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
2164 other => panic!("{other:?}"),
2165 }
2166 }
2167
2168 #[test]
2169 fn checkin_takes_pr_numbers_and_a_dry_run() {
2170 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
2171 Command::Checkin { items, dry_run, .. } => {
2172 assert_eq!(vec![108, 112], items);
2173 assert!(dry_run);
2174 }
2175 other => panic!("{other:?}"),
2176 }
2177 match Cli::parse_from(["spar", "checkin"]).command {
2178 Command::Checkin { items, dry_run, .. } => {
2179 assert!(items.is_empty());
2180 assert!(!dry_run);
2181 }
2182 other => panic!("{other:?}"),
2183 }
2184 }
2185
2186 #[test]
2192 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
2193 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
2194 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
2195 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
2196 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
2197 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
2199 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
2200 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
2201 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
2202 }
2203
2204 #[test]
2205 fn resume_takes_a_next_override() {
2206 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
2207 match cli.command {
2208 Command::Resume {
2209 prs, next_actor, ..
2210 } => {
2211 assert_eq!(vec![108], prs);
2212 assert_eq!(Some("codex".to_string()), next_actor);
2213 }
2214 other => panic!("{other:?}"),
2215 }
2216 }
2217}
2218
2219#[cfg(test)]
2220mod absorb_tests {
2221 use super::*;
2222
2223 #[test]
2224 fn absorb_is_off_unless_asked_for() {
2225 match Cli::parse_from(["spar", "run"]).command {
2226 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
2227 other => panic!("{other:?}"),
2228 }
2229 }
2230
2231 #[test]
2232 fn absorb_takes_a_wave_count() {
2233 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
2234 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
2235 other => panic!("{other:?}"),
2236 }
2237 }
2238
2239 #[test]
2240 fn absorb_is_only_offered_where_issues_are_worked() {
2241 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
2242 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
2243 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
2244 assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
2247 }
2248}
2249
2250#[cfg(test)]
2251mod split_tests {
2252 use super::*;
2253
2254 #[test]
2255 fn split_takes_numbers_of_either_kind() {
2256 match Cli::parse_from(["spar", "split", "10", "12"]).command {
2257 Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
2258 other => panic!("{other:?}"),
2259 }
2260 }
2261
2262 #[test]
2265 fn split_with_no_numbers_is_allowed() {
2266 match Cli::parse_from(["spar", "split"]).command {
2267 Command::Split {
2268 items,
2269 dry_run,
2270 again,
2271 ..
2272 } => {
2273 assert!(items.is_empty());
2274 assert!(!dry_run);
2275 assert!(!again);
2276 }
2277 other => panic!("{other:?}"),
2278 }
2279 }
2280
2281 #[test]
2285 fn split_can_be_told_to_write_nothing() {
2286 match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
2287 Command::Split { dry_run, .. } => assert!(dry_run),
2288 other => panic!("{other:?}"),
2289 }
2290 }
2291
2292 #[test]
2295 fn split_can_be_told_to_split_something_it_already_split() {
2296 match Cli::parse_from(["spar", "split", "10", "--again"]).command {
2297 Command::Split { again, .. } => assert!(again),
2298 other => panic!("{other:?}"),
2299 }
2300 }
2301
2302 #[test]
2305 fn split_takes_the_flags_that_govern_picking() {
2306 match Cli::parse_from([
2307 "spar",
2308 "split",
2309 "--limit",
2310 "5",
2311 "--min-number",
2312 "480",
2313 "--config",
2314 "other.toml",
2315 ])
2316 .command
2317 {
2318 Command::Split { common, .. } => {
2319 assert_eq!(5, common.limit);
2320 assert_eq!(Some(480), common.min_number);
2321 assert_eq!(Some(PathBuf::from("other.toml")), common.config);
2322 }
2323 other => panic!("{other:?}"),
2324 }
2325 }
2326
2327 #[test]
2330 fn split_offers_no_flag_that_would_make_it_a_second_run() {
2331 for flag in [
2332 vec!["--auto-merge"],
2333 vec!["--max-rounds", "2"],
2334 vec!["--close-skipped"],
2335 vec!["--no-close-skipped"],
2336 vec!["--keep-worktrees"],
2337 ] {
2338 let mut argv = vec!["spar", "split", "10"];
2339 argv.extend(flag.iter().copied());
2340 assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
2341 }
2342 }
2343}
2344
2345#[cfg(test)]
2346mod min_number_tests {
2347 use super::*;
2348
2349 fn read(argv: &[&str]) -> Option<i64> {
2350 match Cli::parse_from(argv).command {
2351 Command::Run { common, .. }
2352 | Command::Triage { common, .. }
2353 | Command::Resume { common, .. }
2354 | Command::Review { common, .. }
2355 | Command::Followup { common, .. }
2356 | Command::Split { common, .. }
2357 | Command::Checkin { common, .. } => common.min_number,
2358 other => panic!("{other:?}"),
2359 }
2360 }
2361
2362 #[test]
2363 fn there_is_no_floor_unless_one_is_asked_for() {
2364 assert_eq!(None, read(&["spar", "run"]));
2365 }
2366
2367 #[test]
2368 fn every_command_that_picks_for_itself_accepts_a_floor() {
2369 for cmd in ["run", "triage", "resume", "review", "checkin", "split"] {
2370 assert_eq!(
2371 Some(480),
2372 read(&["spar", cmd, "--min-number", "480"]),
2373 "{cmd}"
2374 );
2375 }
2376 }
2377}
2378
2379#[cfg(test)]
2380mod settings_block_tests {
2381 use super::*;
2382
2383 fn written(line: &str) -> String {
2386 let after = line.split_once('=').expect("an assignment").1;
2387 let mut quoted = false;
2388 for (i, c) in after.char_indices() {
2389 match c {
2390 '"' => quoted = !quoted,
2391 '#' if !quoted => return after[..i].trim().to_string(),
2392 _ => {}
2393 }
2394 }
2395 after.trim().to_string()
2396 }
2397
2398 fn line_for(text: &str, key: &str) -> String {
2399 text.lines()
2400 .find(|l| {
2401 let bare = l.trim_start().trim_start_matches('#').trim_start();
2402 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
2403 })
2404 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
2405 .to_string()
2406 }
2407
2408 #[test]
2415 fn every_value_it_offers_is_the_default_it_actually_has() {
2416 let text = settings_block("claude");
2417 for option in config::known_options() {
2418 if option.section == "loop.effort_schedule" {
2422 continue;
2423 }
2424 let line = line_for(&text, &option.key);
2425 assert_eq!(
2426 option.default,
2427 written(&line),
2428 "the generated config offers `{}`, but the default is {}",
2429 line.trim(),
2430 option.default
2431 );
2432 }
2433 }
2434
2435 #[test]
2439 fn it_offers_every_option_the_parser_knows_about() {
2440 let text = settings_block("claude");
2441 let missing: Vec<String> = config::unmentioned_options(&text)
2442 .into_iter()
2443 .map(|o| format!("[{}] {}", o.section, o.key))
2444 .collect();
2445 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
2446 }
2447
2448 #[test]
2452 fn every_option_it_offers_can_be_uncommented_and_still_load() {
2453 let mut text = String::from(
2454 "[agents.claude]\ncommand = [\"claude\"]\n\n\
2455 [agents.codex]\ncommand = [\"codex\"]\n\n",
2456 );
2457 for line in settings_block("claude").lines() {
2458 text.push_str(uncomment(line).unwrap_or(line));
2459 text.push('\n');
2460 }
2461 let cfg = config::parse(&text).expect("a config of its own suggestions");
2462 assert_eq!("claude", cfg.first_implementor);
2463 }
2464
2465 fn uncomment(line: &str) -> Option<&str> {
2468 let bare = line.trim_start().strip_prefix('#')?.trim_start();
2469 let key = bare.split_once('=')?.0.trim();
2472 let named = !key.is_empty()
2473 && key
2474 .chars()
2475 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
2476 named.then_some(bare)
2477 }
2478
2479 #[test]
2480 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
2481 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
2482 }
2483
2484 #[test]
2489 fn a_note_sits_above_the_option_it_describes() {
2490 let text = settings_block("claude");
2491 assert!(
2492 text.lines().all(|l| l.chars().count() <= 80),
2493 "a line runs off the edge:\n{text}"
2494 );
2495 assert!(
2496 text.lines().all(|l| !l.starts_with(' ')),
2497 "a line is indented, so the columns are back:\n{text}"
2498 );
2499
2500 let lines: Vec<&str> = text.lines().collect();
2502 let at = lines
2503 .iter()
2504 .position(|l| l.starts_with("max_rounds"))
2505 .expect("max_rounds");
2506 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
2507 assert!(
2508 lines[at - 1].contains("lifetime cap"),
2509 "the note above it is the end of its own note: {:?}",
2510 lines[at - 1]
2511 );
2512 }
2513
2514 #[test]
2516 fn every_note_starts_as_a_sentence() {
2517 for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
2518 let first = note.chars().next().expect("a note");
2519 assert!(
2520 first.is_uppercase(),
2521 "{key} reads as a margin scribble rather than a sentence: {note}"
2522 );
2523 }
2524 }
2525}
2526
2527#[cfg(test)]
2528mod agent_block_tests {
2529 use super::*;
2530
2531 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
2532 let mut spec: config::AgentSpec =
2533 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
2534 .try_into()
2535 .expect("builds");
2536 spec.models = models.iter().map(|s| s.to_string()).collect();
2537 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
2538 spec
2539 }
2540
2541 #[test]
2545 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
2546 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
2547 assert!(!block.contains("..."), "{block}");
2548 assert!(!block.contains("effort"), "{block}");
2549 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
2550 }
2551
2552 #[test]
2555 fn only_the_options_that_follow_are_introduced() {
2556 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
2557 assert!(model_only.contains("Omit model to use"), "{model_only}");
2558 assert!(!model_only.contains("Omit effort"), "{model_only}");
2559
2560 let both = agent_block("claude", &spec(&["fable"], &["high"]));
2561 assert!(both.contains("Omit model to use"), "{both}");
2562 assert!(both.contains("Omit effort to use"), "{both}");
2563 }
2564
2565 #[test]
2568 fn a_preset_with_no_hints_still_writes_a_usable_block() {
2569 let block = agent_block("gemini", &spec(&[], &[]));
2570 assert!(!block.contains("..."), "{block}");
2571 assert!(!block.contains("Omit"), "{block}");
2572 assert!(
2573 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
2574 "{block}"
2575 );
2576 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
2578 assert!(block.contains("# timeout = "), "{block}");
2579 }
2580
2581 #[test]
2584 fn the_timeout_offered_is_the_one_the_agent_would_use() {
2585 let mut spec = spec(&["a"], &[]);
2586 spec.timeout = 7200;
2587 assert!(
2588 agent_block("custom", &spec).contains("# timeout = 7200"),
2589 "the generator kept its own copy"
2590 );
2591 }
2592
2593 #[test]
2595 fn alternatives_are_listed_only_when_there_are_any() {
2596 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
2597 let single = agent_block("b", &spec(&["only"], &[]));
2598 assert!(!single.contains("One of:"), "{single}");
2599 }
2600
2601 #[test]
2605 fn the_presets_note_is_said_once() {
2606 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
2607 spec.options_note = Some("Check the current sets with: mytool --help".into());
2608 let block = agent_block("mytool", &spec);
2609 assert_eq!(
2610 1,
2611 block.matches("Check the current sets").count(),
2612 "{block}"
2613 );
2614 }
2615}