1use std::collections::BTreeSet;
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, Ledger, Plan, Status};
15use crate::proc::{self, ExecOpts};
16use crate::repo::Repo;
17use crate::review;
18use crate::review_only;
19use crate::style;
20use crate::triage;
21use crate::{bail, log, logdim, logging, logwarn, spar_err};
22
23pub const VERSION: &str = env!("CARGO_PKG_VERSION");
24
25#[derive(Parser, Debug)]
26#[command(
27 name = "spar",
28 version = VERSION,
29 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
30 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
31 pull request converges. Neither agent reviews its own most recent edit.\n\n\
32 Arguments are issue numbers for `run` and `triage`, and pull request numbers \
33 for `resume`, `review`, and `checkin`. Omit them and spar takes everything \
34 open, up to --limit. `followup` takes none: it works the queue in \
35 .spar/followups.md, and an entry there has no number to name.",
36 max_term_width = 96
37)]
38pub struct Cli {
39 #[arg(short, long, global = true)]
41 pub quiet: bool,
42
43 #[command(subcommand)]
44 pub command: Command,
45}
46
47#[derive(Subcommand, Debug)]
48pub enum Command {
49 Run {
51 issues: Vec<i64>,
53 #[command(flatten)]
54 common: Common,
55 #[command(flatten)]
56 loop_flags: LoopFlags,
57 #[command(flatten)]
58 triage_flags: TriageFlags,
59 #[arg(long, default_value = "plan.json")]
61 plan_out: PathBuf,
62 #[arg(long)]
64 no_worktrees: bool,
65 },
66
67 Followup {
79 #[command(flatten)]
80 common: Common,
81 #[command(flatten)]
82 loop_flags: LoopFlags,
83 #[command(flatten)]
84 triage_flags: TriageFlags,
85 #[arg(long, value_name = "PATH")]
87 file: Option<PathBuf>,
88 #[arg(long)]
90 screen_only: bool,
91 #[arg(long, conflicts_with = "screen_only")]
93 file_only: bool,
94 #[arg(long, default_value = "plan.json")]
96 plan_out: PathBuf,
97 #[arg(long)]
99 no_worktrees: bool,
100 },
101
102 Triage {
104 issues: Vec<i64>,
106 #[command(flatten)]
107 common: Common,
108 #[arg(long, default_value = "plan.json")]
109 plan_out: PathBuf,
110 },
111
112 Resume {
114 prs: Vec<i64>,
116 #[command(flatten)]
117 common: Common,
118 #[command(flatten)]
119 loop_flags: LoopFlags,
120 #[arg(long = "next", value_name = "AGENT")]
122 next_actor: Option<String>,
123 },
124
125 Checkin {
133 items: Vec<i64>,
136 #[command(flatten)]
137 common: Common,
138 #[arg(long)]
140 dry_run: bool,
141 #[arg(long)]
143 reply_only: bool,
144 #[arg(long)]
147 any_author: bool,
148 #[arg(long)]
150 again: bool,
151 #[arg(long)]
153 keep_worktrees: bool,
154 },
155
156 Review {
161 items: Vec<i64>,
164 #[command(flatten)]
165 common: Common,
166 #[arg(long)]
168 dry_run: bool,
169 #[arg(long)]
172 max_rounds: Option<u32>,
173 },
174
175 Post {
180 #[arg(required = true)]
182 prs: Vec<i64>,
183 #[arg(long, default_value = ".")]
184 repo: PathBuf,
185 #[arg(long)]
186 config: Option<PathBuf>,
187 #[arg(long, value_name = "PATH")]
189 file: Option<PathBuf>,
190 #[arg(long)]
192 dry_run: bool,
193 },
194
195 Init {
200 #[arg(long, default_value = "spar.toml")]
201 out: PathBuf,
202 #[arg(long)]
204 force: bool,
205 #[arg(long, conflicts_with = "force")]
208 update: bool,
209 },
210
211 Clean {
213 #[arg(long, default_value = ".")]
214 repo: PathBuf,
215 #[arg(long)]
216 config: Option<PathBuf>,
217 #[arg(long)]
219 all: bool,
220 #[arg(long)]
222 pr_state: bool,
223 },
224
225 Doctor {
227 #[arg(long)]
228 config: Option<PathBuf>,
229 },
230
231 #[command(hide = true)]
235 ScrubFilter,
236}
237
238#[derive(Args, Debug, Clone)]
239pub struct Common {
240 #[arg(long, default_value = ".")]
242 pub repo: PathBuf,
243 #[arg(long)]
245 pub config: Option<PathBuf>,
246 #[arg(long)]
248 pub base: Option<String>,
249 #[arg(long)]
251 pub first: Option<String>,
252 #[arg(long, default_value_t = 20)]
254 pub limit: usize,
255 #[arg(long, value_name = "N")]
258 pub min_number: Option<i64>,
259 #[arg(long, value_name = "TEXT")]
262 pub instructions: Option<String>,
263}
264
265#[derive(Args, Debug, Clone)]
266pub struct LoopFlags {
267 #[arg(long)]
270 pub max_rounds: Option<u32>,
271 #[arg(long)]
273 pub auto_merge: bool,
274 #[arg(long)]
276 pub keep_worktrees: bool,
277 #[arg(long, value_name = "N")]
280 pub absorb: Option<u32>,
281}
282
283#[derive(Args, Debug, Clone)]
286pub struct TriageFlags {
287 #[arg(long, conflicts_with = "no_close_skipped")]
289 pub close_skipped: bool,
290 #[arg(long)]
292 pub no_close_skipped: bool,
293}
294
295pub fn main() -> i32 {
300 let cli = Cli::parse();
301 logging::init_color();
302 logging::set_quiet(cli.quiet);
303
304 match dispatch(cli) {
305 Ok(code) => code,
306 Err(e) => {
307 logging::error(e.to_string());
308 2
309 }
310 }
311}
312
313fn dispatch(cli: Cli) -> Result<i32> {
314 match cli.command {
315 Command::ScrubFilter => cmd_scrub_filter(),
316 Command::Doctor { config } => cmd_doctor(config.as_deref()),
317 Command::Review {
318 items,
319 common,
320 dry_run,
321 max_rounds,
322 } => {
323 let overrides = Overrides {
324 max_rounds,
325 ..Overrides::default()
326 };
327 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
328 let numbers = if items.is_empty() {
329 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
330 if found.is_empty() {
331 log!("no open PRs");
332 return Ok(0);
333 }
334 log!("no PRs given, reviewing {} open", found.len());
335 found
336 } else {
337 items
338 };
339 let sorted = classify(&repo, &numbers)?;
340 let mut targets = sorted.prs;
341 for number in sorted.issues {
342 match repo.open_pr_for_issue(number) {
343 Some(pr) => {
344 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
345 targets.push(pr.number);
346 }
347 None => logwarn!("#{number} is an issue with no open pull request to review"),
348 }
349 }
350 let mut results = Vec::new();
351 for number in targets {
352 results.push(review_only::review_pr(
353 &agents, &cfg, &repo, number, dry_run,
354 ));
355 }
356 if results.is_empty() {
357 return Ok(0);
358 }
359 Ok(report(&results, &cfg))
360 }
361
362 Command::Checkin {
363 items,
364 common,
365 dry_run,
366 reply_only,
367 any_author,
368 again,
369 keep_worktrees,
370 } => {
371 let overrides = Overrides {
372 keep_worktrees: keep_worktrees.then_some(true),
373 ..Overrides::default()
374 };
375 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
376 let mode = checkin::Mode {
377 dry_run,
378 reply_only,
379 trust: if any_author {
380 crate::config::Trust::Anyone
381 } else {
382 cfg.loop_cfg.checkin_trust
383 },
384 again,
385 resolve: cfg.loop_cfg.checkin_resolve,
386 posts: checkin::posts(&cfg),
387 };
388 let numbers = if items.is_empty() {
389 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
390 if found.is_empty() {
391 log!("no open PRs");
392 return Ok(0);
393 }
394 log!(
395 "no PRs given, checking in on {} open: {}",
396 found.len(),
397 found
398 .iter()
399 .map(|n| format!("#{n}"))
400 .collect::<Vec<_>>()
401 .join(", ")
402 );
403 found
404 } else {
405 items
406 };
407 let sorted = classify(&repo, &numbers)?;
408 let mut results = Vec::new();
409 for number in sorted.prs {
410 results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
411 }
412 for number in sorted.issues {
416 match repo.open_pr_for_issue(number) {
417 Some(pr) => {
418 log!(
419 "#{number} is an issue; checking in on its open PR {}",
420 pr.url
421 );
422 results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
423 }
424 None => {
425 results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
426 }
427 }
428 }
429 if results.is_empty() {
430 return Ok(0);
431 }
432 Ok(report(&results, &cfg))
433 }
434
435 Command::Post {
436 prs,
437 repo: repo_path,
438 config,
439 file,
440 dry_run,
441 } => cmd_post(
442 &prs,
443 &repo_path,
444 config.as_deref(),
445 file.as_deref(),
446 dry_run,
447 ),
448
449 Command::Init { out, force, update } => {
450 if update {
451 cmd_init_update(&out)
452 } else {
453 cmd_init(&out, force)
454 }
455 }
456 Command::Clean {
457 repo,
458 config,
459 all,
460 pr_state,
461 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
462 Command::Triage {
463 issues,
464 common,
465 plan_out,
466 } => {
467 let (cfg, repo, agents) = prepare(&common, None)?;
468 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
469 if numbers.is_empty() {
470 return Ok(0);
471 }
472 let sorted = classify(&repo, &numbers)?;
473 for number in &sorted.prs {
474 log!("#{number} is a pull request, nothing to triage");
475 }
476 if sorted.issues.is_empty() {
477 log!("no issues to triage");
478 return Ok(0);
479 }
480 let issues = repo.fetch_issues(&sorted.issues)?;
481 make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
485 Ok(0)
486 }
487 Command::Run {
488 issues,
489 common,
490 loop_flags,
491 triage_flags,
492 plan_out,
493 no_worktrees,
494 } => {
495 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
496 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
497 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
498 if numbers.is_empty() {
499 return Ok(0);
500 }
501 let sorted = classify(&repo, &numbers)?;
502 let mut results = Vec::new();
503 work_issues(
504 &agents,
505 &cfg,
506 &repo,
507 sorted.issues.clone(),
508 &plan_out,
509 &mut results,
510 )?;
511
512 for number in sorted.prs {
513 results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
514 }
515
516 if results.is_empty() {
517 log!("nothing scheduled");
518 return Ok(0);
519 }
520 Ok(report(&results, &cfg))
521 }
522 Command::Followup {
523 common,
524 loop_flags,
525 triage_flags,
526 file,
527 screen_only,
528 file_only,
529 plan_out,
530 no_worktrees,
531 } => {
532 let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
533 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
534 let path = file.unwrap_or_else(|| repo.followups_path());
535 let mode = match (screen_only, file_only) {
536 (true, _) => followups::Mode::ScreenOnly,
537 (_, true) => followups::Mode::FileOnly,
538 _ => followups::Mode::Work,
539 };
540 let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
541
542 let wave = followups::wave(&outcome);
543 if mode != followups::Mode::Work || wave.is_empty() {
544 return Ok(outcome.exit_code());
545 }
546 let mut results = Vec::new();
547 work_issues(&agents, &cfg, &repo, wave, &plan_out, &mut results)?;
548 if results.is_empty() {
549 log!("nothing scheduled");
550 return Ok(outcome.exit_code());
551 }
552 Ok(report(&results, &cfg).max(outcome.exit_code()))
553 }
554
555 Command::Resume {
556 prs,
557 common,
558 loop_flags,
559 next_actor,
560 } => {
561 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
562 if let Some(name) = &next_actor {
563 if !cfg.has_agent(name) {
564 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
565 }
566 }
567 let numbers = if prs.is_empty() {
568 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
569 if found.is_empty() {
570 log!("no open PRs");
571 return Ok(0);
572 }
573 log!(
574 "no PRs given, taking {} open: {}",
575 found.len(),
576 found
577 .iter()
578 .map(|n| format!("#{n}"))
579 .collect::<Vec<_>>()
580 .join(", ")
581 );
582 found
583 } else {
584 prs
585 };
586 let sorted = classify(&repo, &numbers)?;
587 let mut results = Vec::new();
588 for number in sorted.prs {
589 results.push(review::resume_pr(
590 &agents,
591 &cfg,
592 &repo,
593 number,
594 next_actor.as_deref(),
595 ));
596 }
597 for number in sorted.issues {
600 match repo.open_pr_for_issue(number) {
601 Some(pr) => {
602 log!("#{number} is an issue; continuing its open PR {}", pr.url);
603 results.push(review::resume_pr(
604 &agents,
605 &cfg,
606 &repo,
607 pr.number,
608 next_actor.as_deref(),
609 ));
610 }
611 None => logwarn!(
612 "#{number} is an issue with no open pull request. Use `spar run {number}` \
613 to implement it."
614 ),
615 }
616 }
617 if results.is_empty() {
618 return Ok(0);
619 }
620 Ok(report(&results, &cfg))
621 }
622 }
623}
624
625#[derive(Debug, Default, Clone)]
630struct Overrides {
631 max_rounds: Option<u32>,
632 auto_merge: Option<bool>,
633 keep_worktrees: Option<bool>,
634 worktrees: Option<bool>,
635 close_skipped: Option<bool>,
636 absorb: Option<u32>,
637}
638
639impl From<&LoopFlags> for Overrides {
640 fn from(flags: &LoopFlags) -> Self {
641 Self {
642 max_rounds: flags.max_rounds,
643 auto_merge: flags.auto_merge.then_some(true),
644 keep_worktrees: flags.keep_worktrees.then_some(true),
645 worktrees: None,
646 close_skipped: None,
647 absorb: flags.absorb,
648 }
649 }
650}
651
652impl Overrides {
653 fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
656 let mut over = Overrides::from(loop_flags);
657 over.worktrees = if no_worktrees { Some(false) } else { None };
658 over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
659 (true, _) => Some(true),
660 (_, true) => Some(false),
661 _ => None,
662 };
663 over
664 }
665}
666
667fn work_issues(
677 agents: &[Agent],
678 cfg: &Config,
679 repo: &Repo,
680 first_wave: Vec<i64>,
681 plan_out: &Path,
682 results: &mut Vec<IssueRun>,
683) -> Result<()> {
684 let mut ledger = Ledger::new();
685 let mut handled: BTreeSet<i64> = BTreeSet::new();
686 let mut wave = first_wave;
687
688 for round in 0..=cfg.loop_cfg.absorb_new_issues {
693 wave.retain(|n| !handled.contains(n));
694 if wave.is_empty() {
695 break;
696 }
697 if round > 0 {
698 log!(
699 "absorbing {} newly filed issue(s): {}",
700 wave.len(),
701 wave.iter()
702 .map(|n| format!("#{n}"))
703 .collect::<Vec<_>>()
704 .join(", ")
705 );
706 }
707 handled.extend(wave.iter().copied());
708
709 let fetched = match repo.fetch_issues(&wave) {
710 Ok(fetched) => fetched,
711 Err(e) => {
712 logdim!("could not read the next wave: {e}");
713 break;
714 }
715 };
716 let plan_path = if round == 0 {
717 plan_out.to_path_buf()
718 } else {
719 plan_out.with_extension(format!("wave{round}.json"))
720 };
721 let plan = make_plan(agents, cfg, repo, &fetched, &plan_path)?;
722 act_on_plan(cfg, repo, &plan);
723
724 let before = results.len();
725 for item in &plan.order {
726 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
727 continue;
728 };
729 results.push(review::run_issue(
730 agents,
731 cfg,
732 repo,
733 item,
734 issue,
735 &mut ledger,
736 ));
737 }
738
739 wave = results[before..]
741 .iter()
742 .flat_map(|r| r.filed.iter())
743 .filter_map(|url| review::filed_issue_number(url))
744 .collect::<BTreeSet<_>>()
745 .into_iter()
746 .collect();
747 }
748 if !wave.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
749 log!(
750 "{} issue(s) filed in the last wave were left for a later run: {}",
751 wave.len(),
752 wave.iter()
753 .map(|n| format!("#{n}"))
754 .collect::<Vec<_>>()
755 .join(", ")
756 );
757 }
758 Ok(())
759}
760
761fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
762 let mut cfg = config::load(common.config.as_deref())?;
763
764 if let Some(first) = &common.first {
765 if !cfg.has_agent(first) {
766 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
767 }
768 cfg.first_implementor = first.clone();
769 }
770 if let Some(base) = &common.base {
771 cfg.loop_cfg.base_branch = base.clone();
772 }
773 if let Some(min) = common.min_number {
774 cfg.loop_cfg.min_number = min;
775 }
776 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
780 if !extra.is_empty() {
781 let standing = cfg.loop_cfg.instructions.trim();
782 cfg.loop_cfg.instructions = if standing.is_empty() {
783 extra.to_string()
784 } else {
785 format!("{standing}\n{extra}")
786 };
787 }
788 }
789 if let Some(over) = overrides {
790 if let Some(v) = over.max_rounds {
791 if v == 0 {
792 bail!("--max-rounds must be at least 1");
793 }
794 cfg.loop_cfg.max_rounds = v;
795 }
796 if let Some(v) = over.auto_merge {
797 cfg.loop_cfg.auto_merge = v;
798 }
799 if let Some(v) = over.keep_worktrees {
800 cfg.loop_cfg.keep_worktrees = v;
801 }
802 if let Some(v) = over.worktrees {
803 cfg.loop_cfg.worktrees = v;
804 }
805 if let Some(v) = over.close_skipped {
806 cfg.loop_cfg.close_skipped = v;
807 }
808 if let Some(v) = over.absorb {
809 cfg.loop_cfg.absorb_new_issues = v;
810 }
811 }
812
813 let repo = Repo::open(&common.repo, &cfg)?;
814 if common.base.is_none() {
815 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
816 }
817
818 let agents = agent::build(&cfg)?;
819 if let Some(warning) = agent::correlation_warning(&agents) {
820 logging::warn(warning);
821 }
822
823 for stale in repo.prune_worktrees(false) {
825 let what = if stale.starts_with("branch ") {
826 stale
827 } else {
828 format!("worktree {stale}")
829 };
830 logdim!("cleaned up finished {what}");
831 }
832
833 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
834 log!(
835 "agents: {}",
836 agents
837 .iter()
838 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
839 .collect::<Vec<_>>()
840 .join(", ")
841 );
842 Ok((cfg, repo, agents))
843}
844
845fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
846 if !given.is_empty() {
847 if min_number > 0 {
849 let below: Vec<String> = given
850 .iter()
851 .filter(|n| **n < min_number)
852 .map(|n| format!("#{n}"))
853 .collect();
854 if !below.is_empty() {
855 logdim!(
856 "{} below the #{min_number} floor, taking them because you named them",
857 below.join(", ")
858 );
859 }
860 }
861 return Ok(given);
862 }
863 let found = repo.list_open_issues(limit, min_number)?;
864 if found.is_empty() {
865 log!("no open issues");
866 return Ok(found);
867 }
868 log!(
869 "no issues given, taking {} open: {}",
870 found.len(),
871 found
872 .iter()
873 .map(|n| format!("#{n}"))
874 .collect::<Vec<_>>()
875 .join(", ")
876 );
877 Ok(found)
878}
879
880#[derive(Debug, Default)]
886struct Sorted {
887 issues: Vec<i64>,
888 prs: Vec<i64>,
889}
890
891fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
892 let mut sorted = Sorted::default();
893 for number in numbers {
894 match repo.item_kind(*number)? {
895 ItemKind::Issue => sorted.issues.push(*number),
896 ItemKind::Pr => sorted.prs.push(*number),
897 }
898 }
899 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
900 log!(
901 "{} issue(s) and {} pull request(s) given",
902 sorted.issues.len(),
903 sorted.prs.len()
904 );
905 }
906 Ok(sorted)
907}
908
909fn make_plan(
910 agents: &[Agent],
911 cfg: &Config,
912 repo: &Repo,
913 issues: &[Issue],
914 plan_out: &Path,
915) -> Result<Plan> {
916 let plan = triage::triage(agents, cfg, repo, issues)?;
917
918 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
919 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
920 log!("plan written to {}", plan_out.display());
921
922 for item in &plan.order {
923 log!(
924 " do #{} [{}/{}] {}",
925 item.issue,
926 item.complexity,
927 item.risk,
928 item.title
929 );
930 }
931 for item in &plan.skipped {
932 if item.tracker {
933 log!(
934 " hold #{} (both reviewers: tracks work filed elsewhere)",
935 item.issue
936 );
937 } else {
938 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
939 }
940 }
941 for item in &plan.contested {
942 log!(" ?? #{} contested, parked for you to decide", item.issue);
943 }
944 Ok(plan)
945}
946
947fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
957 for item in &plan.skipped {
958 let body = review::skip_comment(item, &repo.style);
959 let close = cfg.loop_cfg.close_skipped && !item.tracker;
960 let outcome = if close {
961 repo.close_issue(item.issue, &body)
962 } else {
963 repo.comment_issue(item.issue, &body)
964 };
965 match outcome {
966 Ok(()) if close => log!(" closed #{}", item.issue),
967 Ok(()) if item.tracker => {
968 log!(
969 " left #{} open, it tracks work filed elsewhere",
970 item.issue
971 )
972 }
973 Ok(()) => {}
974 Err(e) => logdim!("could not update #{}: {e}", item.issue),
975 }
976 }
977}
978
979fn cmd_scrub_filter() -> Result<i32> {
984 let mut input = String::new();
985 std::io::stdin()
986 .read_to_string(&mut input)
987 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
988 let out = style::scrub(&input, &crate::repo::style_from_env());
989 let mut stdout = std::io::stdout();
990 stdout
991 .write_all(out.as_bytes())
992 .and_then(|_| stdout.write_all(b"\n"))
993 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
994 Ok(0)
995}
996
997fn cmd_clean(
998 repo_path: &Path,
999 config_path: Option<&Path>,
1000 all: bool,
1001 pr_state: bool,
1002) -> Result<i32> {
1003 let cfg = config::load(config_path)?;
1004 let repo = Repo::open(repo_path, &cfg)?;
1005 let mut removed = repo.prune_worktrees(all);
1006 removed.extend(repo.prune_state());
1007 if pr_state {
1008 removed.extend(repo.prune_pr_state(None));
1009 }
1010 if removed.is_empty() {
1011 println!("nothing to clean");
1012 } else {
1013 for item in removed {
1014 println!("removed {item}");
1015 }
1016 }
1017 Ok(0)
1018}
1019
1020fn cmd_post(
1022 prs: &[i64],
1023 repo_path: &Path,
1024 config_path: Option<&Path>,
1025 file: Option<&Path>,
1026 dry_run: bool,
1027) -> Result<i32> {
1028 let cfg = config::load(config_path)?;
1029 let repo = Repo::open(repo_path, &cfg)?;
1030
1031 if file.is_some() && prs.len() > 1 {
1032 bail!("--file posts one review, so give it one pull request number");
1033 }
1034
1035 let mut failed = false;
1036 for number in prs {
1037 let text = match file {
1038 Some(path) => std::fs::read_to_string(path)
1039 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1040 None => match repo.read_pending_comment(*number) {
1041 Some(text) => text,
1042 None => {
1043 logging::error(format!(
1044 "no saved review for PR #{number}. `spar review {number} --dry-run` \
1045 produces one, or pass --file."
1046 ));
1047 failed = true;
1048 continue;
1049 }
1050 },
1051 };
1052 if text.trim().is_empty() {
1053 logging::error(format!("the saved review for PR #{number} is empty"));
1054 failed = true;
1055 continue;
1056 }
1057 if dry_run {
1058 println!("\n{}\n", text.trim());
1059 log!("would post the above to PR #{number}");
1060 continue;
1061 }
1062 match repo.comment_pr(*number, &text) {
1065 Ok(()) => log!("posted to PR #{number}"),
1066 Err(e) => {
1067 logging::error(format!("could not post to PR #{number}: {e}"));
1068 failed = true;
1069 }
1070 }
1071 }
1072 Ok(if failed { 1 } else { 0 })
1073}
1074
1075fn cmd_init_update(out: &Path) -> Result<i32> {
1081 let text = std::fs::read_to_string(out)
1082 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1083 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1086
1087 let unset = config::unmentioned_options(&text);
1088 if unset.is_empty() {
1089 println!("{} already mentions every setting.", out.display());
1090 return Ok(0);
1091 }
1092
1093 let mut block = String::new();
1094 if !text.ends_with('\n') {
1095 block.push('\n');
1096 }
1097 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1098 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1099 let mut section = "";
1100 for option in &unset {
1101 if option.section != section {
1102 section = option.section;
1103 block.push_str(&format!("\n# [{section}]\n"));
1104 }
1105 block.push('\n');
1110 block.push_str(&wrap_comment(note_for(&option.key)));
1111 block.push_str(&format!("# {} = {}\n", option.key, option.default));
1112 }
1113
1114 use std::io::Write;
1115 std::fs::OpenOptions::new()
1116 .append(true)
1117 .open(out)
1118 .and_then(|mut f| f.write_all(block.as_bytes()))
1119 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1120
1121 println!(
1122 "added {} setting(s) to {} as comments",
1123 unset.len(),
1124 out.display()
1125 );
1126 Ok(0)
1127}
1128
1129fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1130 if out.exists() && !force {
1131 logging::error(format!(
1132 "{} already exists. `--update` appends any settings it does not mention, \
1133 `--force` overwrites it.",
1134 out.display()
1135 ));
1136 return Ok(1);
1137 }
1138
1139 let presets = config::available_presets();
1140 if presets.is_empty() {
1141 bail!("no presets available, which should be impossible in a released build");
1142 }
1143
1144 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1145 for name in &presets {
1146 let raw = config::load_preset(name)?;
1147 let mut spec: config::AgentSpec = match raw
1151 .as_table()
1152 .cloned()
1153 .ok_or_else(|| spar_err!("not a table"))
1154 .and_then(|t| {
1155 toml::Value::Table(t)
1156 .try_into()
1157 .map_err(|e| spar_err!("{e}"))
1158 }) {
1159 Ok(spec) => spec,
1160 Err(e) => {
1161 println!(" BROKEN {name:10} {}", e.first_line());
1162 continue;
1163 }
1164 };
1165 spec.name = name.clone();
1166 match Agent::new(spec.clone()).resolve_bin() {
1167 Ok(path) => {
1168 println!(" found {name:10} {}", path.display());
1169 found.push((name.clone(), path.to_path_buf(), spec));
1170 }
1171 Err(_) => println!(" missing {name}"),
1172 }
1173 }
1174
1175 if found.len() < 2 {
1176 logging::error(format!(
1177 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1178 presets as a reference.",
1179 found.len(),
1180 out.display()
1181 ));
1182 return Ok(1);
1183 }
1184
1185 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1187 if found.len() > 2 {
1188 log!(
1189 "{} agents available, picking {} and {}. Edit {} to change.",
1190 found.len(),
1191 chosen[0].0,
1192 chosen[1].0,
1193 out.display()
1194 );
1195 }
1196
1197 let mut text = String::from(
1198 "# Generated by `spar init`. Each agent inherits a command template from a\n\
1199 # built in preset; anything set here overrides it.\n\
1200 #\n\
1201 # Commented lines are the other options, each with a working value.\n\
1202 # Uncomment one to change it.\n\n",
1203 );
1204 for (name, _, spec) in &chosen {
1205 text.push_str(&agent_block(name, spec));
1206 }
1207 text.push_str(&settings_block(&chosen[0].0));
1208
1209 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1210 println!("\nwrote {}", out.display());
1211 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1212 Ok(0)
1213}
1214
1215fn report_fallback(agent: &Agent) {
1221 let Some(backup) = agent.fallback() else {
1222 return;
1223 };
1224 match backup.resolve_bin() {
1225 Ok(bin) => println!(
1226 " fallback {} ({})",
1227 bin.display(),
1228 backup.spec.describe()
1229 ),
1230 Err(_) => println!(
1231 " fallback {} not found, so it will not stand in. Set {} to its path.",
1232 backup.program(),
1233 backup.env_key()
1234 ),
1235 }
1236}
1237
1238type Setting = (bool, &'static str, &'static str);
1249
1250const LOOP_OPTIONS: &[Setting] = &[
1251 (false, "max_rounds", "Review rounds one invocation may spend before escalating. Resuming grants a fresh budget, so this is not a lifetime cap on a pull request."),
1252 (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."),
1253 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1254 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1255 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1256 (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."),
1257 (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."),
1258 (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."),
1259 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1260 (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."),
1261 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1262 (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."),
1263 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1264 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1265 (true, "branch_prefix", "Namespace the branches spar creates, for example \"spar/\". Without it they are issue-N and pr-N."),
1266 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1267 (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."),
1268 (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."),
1269 (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."),
1270 (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."),
1271 (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."),
1272 (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."),
1273 (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."),
1274];
1275
1276const STYLE_OPTIONS: &[Setting] = &[
1277 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1278 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1279 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1280 (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."),
1281 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1282 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1283 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1284 (true, "max_body_chars", "A pull request body."),
1285 (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."),
1286];
1287
1288fn settings_block(first_implementor: &str) -> String {
1293 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1294 .into_iter()
1295 .map(|option| (option.key, option.default))
1296 .collect();
1297 let value = |key: &str| match key {
1300 "first_implementor" => format!("\"{first_implementor}\""),
1301 other => defaults.get(other).cloned().unwrap_or_default(),
1302 };
1303
1304 let mut out = String::from("[loop]\n");
1305 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1306 out.push_str(concat!(
1307 "\n[loop.effort_schedule]\n",
1308 "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1309 "# these are examples rather than defaults. Left out, each agent uses\n",
1310 "# the effort its own block asked for.\n",
1311 "# round_1 = \"high\" # the deep first review\n",
1312 "# rest = \"low\" # later rounds only see a small delta\n\n",
1313 ));
1314 out.push_str("[style]\n");
1315 out.push_str(&option_lines(STYLE_OPTIONS, &value));
1316 out
1317}
1318
1319fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1323 let mut out = String::new();
1324 for (commented, key, note) in options {
1325 if !out.is_empty() {
1326 out.push('\n');
1327 }
1328 out.push_str(&wrap_comment(note));
1329 let lead = if *commented { "# " } else { "" };
1330 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
1331 }
1332 out
1333}
1334
1335type Probe = Box<dyn Fn() -> Result<String>>;
1338
1339fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1346 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1347
1348 if let Some(extra) = &spec.options_note {
1360 if !spec.models.is_empty() || !spec.efforts.is_empty() {
1361 out.push('\n');
1362 out.push_str(&wrap_comment(extra));
1363 }
1364 }
1365 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
1366 let Some(suggested) = choices.first() else {
1367 continue;
1368 };
1369 let mut note = format!("Omit {key} to use the CLI's own default.");
1370 if choices.len() > 1 {
1371 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
1372 }
1373 out.push('\n');
1374 out.push_str(&wrap_comment(¬e));
1375 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
1376 }
1377
1378 out.push('\n');
1382 out.push_str(&wrap_comment(
1383 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
1384 never retried, so err long.",
1385 ));
1386 out.push_str(&format!("# timeout = {}\n", spec.timeout));
1387
1388 let backup = if name == "cursor" { "gemini" } else { "cursor" };
1391 out.push('\n');
1392 out.push_str(&wrap_comment(
1393 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
1394 of this agent, never alongside it.",
1395 ));
1396 out.push_str(&format!(
1397 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1398 ));
1399
1400 out.push('\n');
1405 out.push_str(&wrap_comment(
1406 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
1407 that has no preset.",
1408 ));
1409 out.push('\n');
1410 out
1411}
1412
1413fn note_for(key: &str) -> &'static str {
1419 LOOP_OPTIONS
1420 .iter()
1421 .chain(STYLE_OPTIONS)
1422 .find(|(_, name, _)| *name == key)
1423 .map(|(_, _, note)| *note)
1424 .unwrap_or("")
1425}
1426
1427fn wrap_comment(text: &str) -> String {
1429 const WIDTH: usize = 76;
1430 let mut out = String::new();
1431 let mut line = String::from("#");
1432 for word in text.split_whitespace() {
1433 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1434 out.push_str(&line);
1435 out.push('\n');
1436 line = String::from("#");
1437 }
1438 line.push(' ');
1439 line.push_str(word);
1440 }
1441 if line.len() > 1 {
1442 out.push_str(&line);
1443 out.push('\n');
1444 }
1445 out
1446}
1447
1448fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1449 let mut ok = true;
1450
1451 let probes: Vec<(&str, Probe)> = vec![
1452 (
1453 "git",
1454 Box::new(|| {
1455 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1456 .map(|s| first_line(&s))
1457 }),
1458 ),
1459 (
1460 "gh",
1461 Box::new(|| {
1462 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1463 .map(|s| first_line(&s))
1464 }),
1465 ),
1466 (
1467 "gh auth",
1468 Box::new(|| {
1469 let out = proc::exec(
1470 &["gh".into(), "auth".into(), "status".into()],
1471 &ExecOpts::new().check(false).timeout_secs(60),
1472 )?;
1473 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1474 if out.ok() {
1475 Ok(first_line(&text))
1476 } else {
1477 Err(spar_err!("not authenticated. Run `gh auth login`."))
1478 }
1479 }),
1480 ),
1481 ];
1482
1483 for (label, probe) in probes {
1484 match probe() {
1485 Ok(detail) => println!(" ok {label:12} {detail}"),
1486 Err(e) => {
1487 println!(" FAIL {label:12} {}", e.first_line());
1488 ok = false;
1489 }
1490 }
1491 }
1492
1493 let found = config::find_config(config_path)?;
1494 let Some(path) = found else {
1495 println!("\n no spar.toml found. Run `spar init` to generate one.");
1496 println!(
1497 " presets available: {}",
1498 config::available_presets().join(", ")
1499 );
1500 return Ok(if ok { 0 } else { 1 });
1501 };
1502
1503 println!("\n config: {}", path.display());
1504 let cfg = match config::load(Some(&path)) {
1505 Ok(cfg) => cfg,
1506 Err(e) => {
1507 println!(" FAIL config {e}");
1508 return Ok(1);
1509 }
1510 };
1511
1512 let mut resolved = Vec::new();
1515 for spec in &cfg.agents {
1516 let agent = Agent::new(spec.clone());
1517 match agent.resolve_bin() {
1518 Ok(bin) => {
1519 println!(
1520 " ok {:12} {} ({})",
1521 spec.name,
1522 bin.display(),
1523 spec.describe()
1524 );
1525 report_fallback(&agent);
1526 resolved.push(agent);
1527 }
1528 Err(e) => {
1529 println!(" FAIL {:12} {}", spec.name, e.first_line());
1530 ok = false;
1531 }
1532 }
1533 }
1534
1535 if resolved.len() == cfg.agents.len() {
1536 if let Some(warning) = agent::correlation_warning(&resolved) {
1537 println!("\n WARNING {warning}");
1538 }
1539 }
1540
1541 println!(
1542 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1543 cfg.loop_cfg.max_rounds,
1544 cfg.loop_cfg.auto_merge,
1545 cfg.loop_cfg.worktrees,
1546 cfg.loop_cfg.followups,
1547 cfg.style.terse
1548 );
1549 if let Ok(text) = std::fs::read_to_string(&path) {
1553 let unset = config::unmentioned_options(&text);
1554 if !unset.is_empty() {
1555 println!(
1556 "\n {} setting(s) this config does not mention, all at their defaults:",
1557 unset.len()
1558 );
1559 for option in &unset {
1560 println!(
1561 " [{}] {} = {}",
1562 option.section, option.key, option.default
1563 );
1564 }
1565 println!(
1566 " `spar init --update {}` appends them as comments.",
1567 path.display()
1568 );
1569 }
1570 }
1571
1572 println!(
1573 "{}",
1574 if ok {
1575 "\nready"
1576 } else {
1577 "\nmissing prerequisites"
1578 }
1579 );
1580 Ok(if ok { 0 } else { 1 })
1581}
1582
1583fn first_line(text: &str) -> String {
1584 text.trim().lines().next().unwrap_or("").trim().to_string()
1585}
1586
1587fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1592 println!("\n{}", "=".repeat(60));
1593 for r in results {
1594 println!(
1595 "#{:<5} {:<10} rounds={} {}",
1596 r.issue,
1597 r.status.to_string(),
1598 r.rounds,
1599 r.pr.as_deref().unwrap_or("")
1600 );
1601 for note in &r.notes {
1602 println!(" {}", first_line(note));
1603 }
1604 for url in &r.filed {
1605 println!(" filed {url}");
1606 }
1607 for dispute in &r.disputes {
1608 println!(" disputed: {}", dispute.title);
1609 }
1610 }
1611 println!("{}", "=".repeat(60));
1612
1613 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1614 println!("\nApproved PRs are waiting on you to merge.");
1615 }
1616 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1617 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1618 println!(
1619 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1620 Set followups = \"issues\" to file them."
1621 );
1622 }
1623 if results.iter().all(IssueRun::succeeded) {
1624 0
1625 } else {
1626 1
1627 }
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632 use super::*;
1633 use clap::CommandFactory;
1634
1635 #[test]
1636 fn the_parser_is_internally_consistent() {
1637 Cli::command().debug_assert();
1638 }
1639
1640 #[test]
1641 fn quiet_is_accepted_before_or_after_the_subcommand() {
1642 for argv in [
1643 vec!["spar", "--quiet", "run", "42"],
1644 vec!["spar", "run", "42", "--quiet"],
1645 vec!["spar", "resume", "--quiet"],
1646 vec!["spar", "init", "-q"],
1647 ] {
1648 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1649 }
1650 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1651 }
1652
1653 #[test]
1654 fn several_issue_numbers_are_accepted() {
1655 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1656 match cli.command {
1657 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1658 other => panic!("{other:?}"),
1659 }
1660 }
1661
1662 #[test]
1663 fn issue_numbers_and_flags_can_be_interleaved() {
1664 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1665 match cli.command {
1666 Command::Run {
1667 issues, loop_flags, ..
1668 } => {
1669 assert_eq!(vec![42, 51], issues);
1670 assert!(loop_flags.auto_merge);
1671 }
1672 other => panic!("{other:?}"),
1673 }
1674 }
1675
1676 #[test]
1677 fn every_command_that_reads_a_config_accepts_one() {
1678 for argv in [
1679 vec!["spar", "run", "42"],
1680 vec!["spar", "triage"],
1681 vec!["spar", "resume"],
1682 vec!["spar", "followup"],
1683 vec!["spar", "checkin"],
1684 vec!["spar", "clean"],
1685 vec!["spar", "doctor"],
1686 ] {
1687 let mut full = argv.clone();
1688 full.extend(["--config", "other.toml"]);
1689 let cli = Cli::parse_from(&full);
1690 let config = match cli.command {
1691 Command::Run { common, .. }
1692 | Command::Triage { common, .. }
1693 | Command::Resume { common, .. }
1694 | Command::Followup { common, .. }
1695 | Command::Checkin { common, .. } => common.config,
1696 Command::Clean { config, .. } | Command::Doctor { config } => config,
1697 other => panic!("{other:?}"),
1698 };
1699 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1700 }
1701 }
1702
1703 #[test]
1704 fn auto_merge_is_off_unless_asked_for() {
1705 let cli = Cli::parse_from(["spar", "run"]);
1706 match cli.command {
1707 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1708 other => panic!("{other:?}"),
1709 }
1710 }
1711
1712 #[test]
1715 fn every_command_that_reads_a_config_takes_instructions() {
1716 for argv in [
1719 vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
1720 vec![
1721 "spar",
1722 "triage",
1723 "7",
1724 "--instructions",
1725 "Do not wait for CI.",
1726 ],
1727 vec![
1728 "spar",
1729 "resume",
1730 "7",
1731 "--instructions",
1732 "Do not wait for CI.",
1733 ],
1734 vec![
1735 "spar",
1736 "review",
1737 "7",
1738 "--instructions",
1739 "Do not wait for CI.",
1740 ],
1741 vec!["spar", "followup", "--instructions", "Do not wait for CI."],
1742 vec![
1743 "spar",
1744 "checkin",
1745 "7",
1746 "--instructions",
1747 "Do not wait for CI.",
1748 ],
1749 ] {
1750 let parsed = Cli::parse_from(&argv);
1751 let common = match parsed.command {
1752 Command::Run { common, .. }
1753 | Command::Triage { common, .. }
1754 | Command::Resume { common, .. }
1755 | Command::Review { common, .. }
1756 | Command::Followup { common, .. }
1757 | Command::Checkin { common, .. } => common,
1758 other => panic!("{other:?}"),
1759 };
1760 assert_eq!(
1761 Some("Do not wait for CI."),
1762 common.instructions.as_deref(),
1763 "{argv:?}"
1764 );
1765 }
1766 }
1767
1768 #[test]
1769 fn the_two_close_skipped_flags_are_mutually_exclusive() {
1770 assert!(
1771 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1772 );
1773 }
1774
1775 #[test]
1778 fn close_skipped_is_offered_only_where_it_means_something() {
1779 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1780 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1781 assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
1783 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1784 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1785 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1786 }
1787
1788 #[test]
1791 fn followup_takes_no_numbers() {
1792 assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
1793 assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
1794 }
1795
1796 #[test]
1799 fn the_two_stopping_points_are_mutually_exclusive() {
1800 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
1801 assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
1802 assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
1803 }
1804
1805 #[test]
1806 fn the_close_skipped_pair_resolves_to_a_tristate() {
1807 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1808 Command::Run { triage_flags, .. } => {
1809 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1810 (true, _) => Some(true),
1811 (_, true) => Some(false),
1812 _ => None,
1813 }
1814 }
1815 other => panic!("{other:?}"),
1816 };
1817 assert_eq!(None, read(&["spar", "run"]));
1818 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1819 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1820 }
1821
1822 #[test]
1823 fn the_default_limit_is_twenty() {
1824 let cli = Cli::parse_from(["spar", "run"]);
1825 match cli.command {
1826 Command::Run { common, .. } => assert_eq!(20, common.limit),
1827 other => panic!("{other:?}"),
1828 }
1829 }
1830
1831 #[test]
1832 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1833 assert!(matches!(
1834 Cli::parse_from(["spar", "scrub-filter"]).command,
1835 Command::ScrubFilter
1836 ));
1837 let help = Cli::command().render_long_help().to_string();
1838 assert!(
1839 !help.contains("scrub-filter"),
1840 "it is plumbing, not a command"
1841 );
1842 }
1843
1844 #[test]
1845 fn review_takes_pr_numbers_and_a_dry_run() {
1846 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1847 match cli.command {
1848 Command::Review { items, dry_run, .. } => {
1849 assert_eq!(vec![101, 102], items);
1850 assert!(dry_run);
1851 }
1852 other => panic!("{other:?}"),
1853 }
1854 }
1855
1856 #[test]
1857 fn review_posts_unless_told_not_to() {
1858 match Cli::parse_from(["spar", "review", "101"]).command {
1859 Command::Review { dry_run, .. } => assert!(!dry_run),
1860 other => panic!("{other:?}"),
1861 }
1862 }
1863
1864 #[test]
1865 fn review_with_no_numbers_is_allowed() {
1866 match Cli::parse_from(["spar", "review"]).command {
1867 Command::Review { items, .. } => assert!(items.is_empty()),
1868 other => panic!("{other:?}"),
1869 }
1870 }
1871
1872 #[test]
1873 fn review_takes_its_own_round_budget() {
1874 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1875 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1876 other => panic!("{other:?}"),
1877 }
1878 }
1879
1880 #[test]
1881 fn checkin_takes_pr_numbers_and_a_dry_run() {
1882 match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
1883 Command::Checkin { items, dry_run, .. } => {
1884 assert_eq!(vec![108, 112], items);
1885 assert!(dry_run);
1886 }
1887 other => panic!("{other:?}"),
1888 }
1889 match Cli::parse_from(["spar", "checkin"]).command {
1890 Command::Checkin { items, dry_run, .. } => {
1891 assert!(items.is_empty());
1892 assert!(!dry_run);
1893 }
1894 other => panic!("{other:?}"),
1895 }
1896 }
1897
1898 #[test]
1904 fn checkin_offers_no_flag_that_would_weaken_the_pair() {
1905 assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
1906 assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
1907 assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
1908 assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
1909 assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
1911 assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
1912 assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
1913 assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
1914 }
1915
1916 #[test]
1917 fn resume_takes_a_next_override() {
1918 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1919 match cli.command {
1920 Command::Resume {
1921 prs, next_actor, ..
1922 } => {
1923 assert_eq!(vec![108], prs);
1924 assert_eq!(Some("codex".to_string()), next_actor);
1925 }
1926 other => panic!("{other:?}"),
1927 }
1928 }
1929}
1930
1931#[cfg(test)]
1932mod absorb_tests {
1933 use super::*;
1934
1935 #[test]
1936 fn absorb_is_off_unless_asked_for() {
1937 match Cli::parse_from(["spar", "run"]).command {
1938 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1939 other => panic!("{other:?}"),
1940 }
1941 }
1942
1943 #[test]
1944 fn absorb_takes_a_wave_count() {
1945 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1946 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1947 other => panic!("{other:?}"),
1948 }
1949 }
1950
1951 #[test]
1952 fn absorb_is_only_offered_where_issues_are_worked() {
1953 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1954 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1955 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1956 }
1957}
1958
1959#[cfg(test)]
1960mod min_number_tests {
1961 use super::*;
1962
1963 fn read(argv: &[&str]) -> Option<i64> {
1964 match Cli::parse_from(argv).command {
1965 Command::Run { common, .. }
1966 | Command::Triage { common, .. }
1967 | Command::Resume { common, .. }
1968 | Command::Review { common, .. }
1969 | Command::Followup { common, .. }
1970 | Command::Checkin { common, .. } => common.min_number,
1971 other => panic!("{other:?}"),
1972 }
1973 }
1974
1975 #[test]
1976 fn there_is_no_floor_unless_one_is_asked_for() {
1977 assert_eq!(None, read(&["spar", "run"]));
1978 }
1979
1980 #[test]
1981 fn every_command_that_picks_for_itself_accepts_a_floor() {
1982 for cmd in ["run", "triage", "resume", "review", "checkin"] {
1983 assert_eq!(
1984 Some(480),
1985 read(&["spar", cmd, "--min-number", "480"]),
1986 "{cmd}"
1987 );
1988 }
1989 }
1990}
1991
1992#[cfg(test)]
1993mod settings_block_tests {
1994 use super::*;
1995
1996 fn written(line: &str) -> String {
1999 let after = line.split_once('=').expect("an assignment").1;
2000 let mut quoted = false;
2001 for (i, c) in after.char_indices() {
2002 match c {
2003 '"' => quoted = !quoted,
2004 '#' if !quoted => return after[..i].trim().to_string(),
2005 _ => {}
2006 }
2007 }
2008 after.trim().to_string()
2009 }
2010
2011 fn line_for(text: &str, key: &str) -> String {
2012 text.lines()
2013 .find(|l| {
2014 let bare = l.trim_start().trim_start_matches('#').trim_start();
2015 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
2016 })
2017 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
2018 .to_string()
2019 }
2020
2021 #[test]
2028 fn every_value_it_offers_is_the_default_it_actually_has() {
2029 let text = settings_block("claude");
2030 for option in config::known_options() {
2031 if option.section == "loop.effort_schedule" {
2035 continue;
2036 }
2037 let line = line_for(&text, &option.key);
2038 assert_eq!(
2039 option.default,
2040 written(&line),
2041 "the generated config offers `{}`, but the default is {}",
2042 line.trim(),
2043 option.default
2044 );
2045 }
2046 }
2047
2048 #[test]
2052 fn it_offers_every_option_the_parser_knows_about() {
2053 let text = settings_block("claude");
2054 let missing: Vec<String> = config::unmentioned_options(&text)
2055 .into_iter()
2056 .map(|o| format!("[{}] {}", o.section, o.key))
2057 .collect();
2058 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
2059 }
2060
2061 #[test]
2065 fn every_option_it_offers_can_be_uncommented_and_still_load() {
2066 let mut text = String::from(
2067 "[agents.claude]\ncommand = [\"claude\"]\n\n\
2068 [agents.codex]\ncommand = [\"codex\"]\n\n",
2069 );
2070 for line in settings_block("claude").lines() {
2071 text.push_str(uncomment(line).unwrap_or(line));
2072 text.push('\n');
2073 }
2074 let cfg = config::parse(&text).expect("a config of its own suggestions");
2075 assert_eq!("claude", cfg.first_implementor);
2076 }
2077
2078 fn uncomment(line: &str) -> Option<&str> {
2081 let bare = line.trim_start().strip_prefix('#')?.trim_start();
2082 let key = bare.split_once('=')?.0.trim();
2085 let named = !key.is_empty()
2086 && key
2087 .chars()
2088 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
2089 named.then_some(bare)
2090 }
2091
2092 #[test]
2093 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
2094 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
2095 }
2096
2097 #[test]
2102 fn a_note_sits_above_the_option_it_describes() {
2103 let text = settings_block("claude");
2104 assert!(
2105 text.lines().all(|l| l.chars().count() <= 80),
2106 "a line runs off the edge:\n{text}"
2107 );
2108 assert!(
2109 text.lines().all(|l| !l.starts_with(' ')),
2110 "a line is indented, so the columns are back:\n{text}"
2111 );
2112
2113 let lines: Vec<&str> = text.lines().collect();
2115 let at = lines
2116 .iter()
2117 .position(|l| l.starts_with("max_rounds"))
2118 .expect("max_rounds");
2119 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
2120 assert!(
2121 lines[at - 1].contains("lifetime cap"),
2122 "the note above it is the end of its own note: {:?}",
2123 lines[at - 1]
2124 );
2125 }
2126
2127 #[test]
2129 fn every_note_starts_as_a_sentence() {
2130 for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
2131 let first = note.chars().next().expect("a note");
2132 assert!(
2133 first.is_uppercase(),
2134 "{key} reads as a margin scribble rather than a sentence: {note}"
2135 );
2136 }
2137 }
2138}
2139
2140#[cfg(test)]
2141mod agent_block_tests {
2142 use super::*;
2143
2144 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
2145 let mut spec: config::AgentSpec =
2146 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
2147 .try_into()
2148 .expect("builds");
2149 spec.models = models.iter().map(|s| s.to_string()).collect();
2150 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
2151 spec
2152 }
2153
2154 #[test]
2158 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
2159 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
2160 assert!(!block.contains("..."), "{block}");
2161 assert!(!block.contains("effort"), "{block}");
2162 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
2163 }
2164
2165 #[test]
2168 fn only_the_options_that_follow_are_introduced() {
2169 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
2170 assert!(model_only.contains("Omit model to use"), "{model_only}");
2171 assert!(!model_only.contains("Omit effort"), "{model_only}");
2172
2173 let both = agent_block("claude", &spec(&["fable"], &["high"]));
2174 assert!(both.contains("Omit model to use"), "{both}");
2175 assert!(both.contains("Omit effort to use"), "{both}");
2176 }
2177
2178 #[test]
2181 fn a_preset_with_no_hints_still_writes_a_usable_block() {
2182 let block = agent_block("gemini", &spec(&[], &[]));
2183 assert!(!block.contains("..."), "{block}");
2184 assert!(!block.contains("Omit"), "{block}");
2185 assert!(
2186 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
2187 "{block}"
2188 );
2189 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
2191 assert!(block.contains("# timeout = "), "{block}");
2192 }
2193
2194 #[test]
2197 fn the_timeout_offered_is_the_one_the_agent_would_use() {
2198 let mut spec = spec(&["a"], &[]);
2199 spec.timeout = 7200;
2200 assert!(
2201 agent_block("custom", &spec).contains("# timeout = 7200"),
2202 "the generator kept its own copy"
2203 );
2204 }
2205
2206 #[test]
2208 fn alternatives_are_listed_only_when_there_are_any() {
2209 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
2210 let single = agent_block("b", &spec(&["only"], &[]));
2211 assert!(!single.contains("One of:"), "{single}");
2212 }
2213
2214 #[test]
2218 fn the_presets_note_is_said_once() {
2219 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
2220 spec.options_note = Some("Check the current sets with: mytool --help".into());
2221 let block = agent_block("mytool", &spec);
2222 assert_eq!(
2223 1,
2224 block.matches("Check the current sets").count(),
2225 "{block}"
2226 );
2227 }
2228}