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::config::{self, Config};
11use crate::error::Result;
12use crate::model::{Issue, IssueRun, ItemKind, Ledger, Plan, Status};
13use crate::proc::{self, ExecOpts};
14use crate::repo::Repo;
15use crate::review;
16use crate::review_only;
17use crate::style;
18use crate::triage;
19use crate::{bail, log, logdim, logging, logwarn, spar_err};
20
21pub const VERSION: &str = env!("CARGO_PKG_VERSION");
22
23#[derive(Parser, Debug)]
24#[command(
25 name = "spar",
26 version = VERSION,
27 about = "Two coding agents alternate implementing and reviewing GitHub issues.",
28 long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
29 pull request converges. Neither agent reviews its own most recent edit.\n\n\
30 Arguments are issue numbers for `run` and `triage`, and pull request numbers \
31 for `resume`. Omit them and spar takes everything open, up to --limit.",
32 max_term_width = 96
33)]
34pub struct Cli {
35 #[arg(short, long, global = true)]
37 pub quiet: bool,
38
39 #[command(subcommand)]
40 pub command: Command,
41}
42
43#[derive(Subcommand, Debug)]
44pub enum Command {
45 Run {
47 issues: Vec<i64>,
49 #[command(flatten)]
50 common: Common,
51 #[command(flatten)]
52 loop_flags: LoopFlags,
53 #[command(flatten)]
54 triage_flags: TriageFlags,
55 #[arg(long, default_value = "plan.json")]
57 plan_out: PathBuf,
58 #[arg(long)]
60 no_worktrees: bool,
61 },
62
63 Triage {
65 issues: Vec<i64>,
67 #[command(flatten)]
68 common: Common,
69 #[arg(long, default_value = "plan.json")]
70 plan_out: PathBuf,
71 },
72
73 Resume {
75 prs: Vec<i64>,
77 #[command(flatten)]
78 common: Common,
79 #[command(flatten)]
80 loop_flags: LoopFlags,
81 #[arg(long = "next", value_name = "AGENT")]
83 next_actor: Option<String>,
84 },
85
86 Review {
91 items: Vec<i64>,
94 #[command(flatten)]
95 common: Common,
96 #[arg(long)]
98 dry_run: bool,
99 #[arg(long)]
102 max_rounds: Option<u32>,
103 },
104
105 Post {
110 #[arg(required = true)]
112 prs: Vec<i64>,
113 #[arg(long, default_value = ".")]
114 repo: PathBuf,
115 #[arg(long)]
116 config: Option<PathBuf>,
117 #[arg(long, value_name = "PATH")]
119 file: Option<PathBuf>,
120 #[arg(long)]
122 dry_run: bool,
123 },
124
125 Init {
130 #[arg(long, default_value = "spar.toml")]
131 out: PathBuf,
132 #[arg(long)]
134 force: bool,
135 #[arg(long, conflicts_with = "force")]
138 update: bool,
139 },
140
141 Clean {
143 #[arg(long, default_value = ".")]
144 repo: PathBuf,
145 #[arg(long)]
146 config: Option<PathBuf>,
147 #[arg(long)]
149 all: bool,
150 #[arg(long)]
152 pr_state: bool,
153 },
154
155 Doctor {
157 #[arg(long)]
158 config: Option<PathBuf>,
159 },
160
161 #[command(hide = true)]
165 ScrubFilter,
166}
167
168#[derive(Args, Debug, Clone)]
169pub struct Common {
170 #[arg(long, default_value = ".")]
172 pub repo: PathBuf,
173 #[arg(long)]
175 pub config: Option<PathBuf>,
176 #[arg(long)]
178 pub base: Option<String>,
179 #[arg(long)]
181 pub first: Option<String>,
182 #[arg(long, default_value_t = 20)]
184 pub limit: usize,
185 #[arg(long, value_name = "N")]
188 pub min_number: Option<i64>,
189 #[arg(long, value_name = "TEXT")]
192 pub instructions: Option<String>,
193}
194
195#[derive(Args, Debug, Clone)]
196pub struct LoopFlags {
197 #[arg(long)]
200 pub max_rounds: Option<u32>,
201 #[arg(long)]
203 pub auto_merge: bool,
204 #[arg(long)]
206 pub keep_worktrees: bool,
207 #[arg(long, value_name = "N")]
210 pub absorb: Option<u32>,
211}
212
213#[derive(Args, Debug, Clone)]
216pub struct TriageFlags {
217 #[arg(long, conflicts_with = "no_close_skipped")]
219 pub close_skipped: bool,
220 #[arg(long)]
222 pub no_close_skipped: bool,
223}
224
225pub fn main() -> i32 {
230 let cli = Cli::parse();
231 logging::init_color();
232 logging::set_quiet(cli.quiet);
233
234 match dispatch(cli) {
235 Ok(code) => code,
236 Err(e) => {
237 logging::error(e.to_string());
238 2
239 }
240 }
241}
242
243fn dispatch(cli: Cli) -> Result<i32> {
244 match cli.command {
245 Command::ScrubFilter => cmd_scrub_filter(),
246 Command::Doctor { config } => cmd_doctor(config.as_deref()),
247 Command::Review {
248 items,
249 common,
250 dry_run,
251 max_rounds,
252 } => {
253 let overrides = Overrides {
254 max_rounds,
255 ..Overrides::default()
256 };
257 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
258 let numbers = if items.is_empty() {
259 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
260 if found.is_empty() {
261 log!("no open PRs");
262 return Ok(0);
263 }
264 log!("no PRs given, reviewing {} open", found.len());
265 found
266 } else {
267 items
268 };
269 let sorted = classify(&repo, &numbers)?;
270 let mut targets = sorted.prs;
271 for number in sorted.issues {
272 match repo.open_pr_for_issue(number) {
273 Some(pr) => {
274 log!("#{number} is an issue; reviewing its open PR {}", pr.url);
275 targets.push(pr.number);
276 }
277 None => logwarn!("#{number} is an issue with no open pull request to review"),
278 }
279 }
280 let mut results = Vec::new();
281 for number in targets {
282 results.push(review_only::review_pr(
283 &agents, &cfg, &repo, number, dry_run,
284 ));
285 }
286 if results.is_empty() {
287 return Ok(0);
288 }
289 Ok(report(&results, &cfg))
290 }
291
292 Command::Post {
293 prs,
294 repo: repo_path,
295 config,
296 file,
297 dry_run,
298 } => cmd_post(
299 &prs,
300 &repo_path,
301 config.as_deref(),
302 file.as_deref(),
303 dry_run,
304 ),
305
306 Command::Init { out, force, update } => {
307 if update {
308 cmd_init_update(&out)
309 } else {
310 cmd_init(&out, force)
311 }
312 }
313 Command::Clean {
314 repo,
315 config,
316 all,
317 pr_state,
318 } => cmd_clean(&repo, config.as_deref(), all, pr_state),
319 Command::Triage {
320 issues,
321 common,
322 plan_out,
323 } => {
324 let (cfg, repo, agents) = prepare(&common, None)?;
325 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
326 if numbers.is_empty() {
327 return Ok(0);
328 }
329 let sorted = classify(&repo, &numbers)?;
330 for number in &sorted.prs {
331 log!("#{number} is a pull request, nothing to triage");
332 }
333 if sorted.issues.is_empty() {
334 log!("no issues to triage");
335 return Ok(0);
336 }
337 let issues = repo.fetch_issues(&sorted.issues)?;
338 make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
342 Ok(0)
343 }
344 Command::Run {
345 issues,
346 common,
347 loop_flags,
348 triage_flags,
349 plan_out,
350 no_worktrees,
351 } => {
352 let mut overrides = Overrides::from(&loop_flags);
353 overrides.worktrees = if no_worktrees { Some(false) } else { None };
354 overrides.close_skipped =
355 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
356 (true, _) => Some(true),
357 (_, true) => Some(false),
358 _ => None,
359 };
360 let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
361 let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
362 if numbers.is_empty() {
363 return Ok(0);
364 }
365 let sorted = classify(&repo, &numbers)?;
366 let mut results = Vec::new();
367 let mut ledger = Ledger::new();
368 let mut handled: BTreeSet<i64> = BTreeSet::new();
369 let mut wave = sorted.issues.clone();
370
371 for round in 0..=cfg.loop_cfg.absorb_new_issues {
376 wave.retain(|n| !handled.contains(n));
377 if wave.is_empty() {
378 break;
379 }
380 if round > 0 {
381 log!(
382 "absorbing {} newly filed issue(s): {}",
383 wave.len(),
384 wave.iter()
385 .map(|n| format!("#{n}"))
386 .collect::<Vec<_>>()
387 .join(", ")
388 );
389 }
390 handled.extend(wave.iter().copied());
391
392 let fetched = match repo.fetch_issues(&wave) {
393 Ok(fetched) => fetched,
394 Err(e) => {
395 logdim!("could not read the next wave: {e}");
396 break;
397 }
398 };
399 let plan_path = if round == 0 {
400 plan_out.clone()
401 } else {
402 plan_out.with_extension(format!("wave{round}.json"))
403 };
404 let plan = make_plan(&agents, &cfg, &repo, &fetched, &plan_path)?;
405 act_on_plan(&cfg, &repo, &plan);
406
407 let before = results.len();
408 for item in &plan.order {
409 let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
410 continue;
411 };
412 results.push(review::run_issue(
413 &agents,
414 &cfg,
415 &repo,
416 item,
417 issue,
418 &mut ledger,
419 ));
420 }
421
422 wave = results[before..]
424 .iter()
425 .flat_map(|r| r.filed.iter())
426 .filter_map(|url| review::filed_issue_number(url))
427 .collect::<BTreeSet<_>>()
428 .into_iter()
429 .collect();
430 }
431 if !wave.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
432 log!(
433 "{} issue(s) filed in the last wave were left for a later run: {}",
434 wave.len(),
435 wave.iter()
436 .map(|n| format!("#{n}"))
437 .collect::<Vec<_>>()
438 .join(", ")
439 );
440 }
441
442 for number in sorted.prs {
443 results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
444 }
445
446 if results.is_empty() {
447 log!("nothing scheduled");
448 return Ok(0);
449 }
450 Ok(report(&results, &cfg))
451 }
452 Command::Resume {
453 prs,
454 common,
455 loop_flags,
456 next_actor,
457 } => {
458 let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
459 if let Some(name) = &next_actor {
460 if !cfg.has_agent(name) {
461 bail!("--next must be one of: {}", cfg.agent_names().join(", "));
462 }
463 }
464 let numbers = if prs.is_empty() {
465 let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
466 if found.is_empty() {
467 log!("no open PRs");
468 return Ok(0);
469 }
470 log!(
471 "no PRs given, taking {} open: {}",
472 found.len(),
473 found
474 .iter()
475 .map(|n| format!("#{n}"))
476 .collect::<Vec<_>>()
477 .join(", ")
478 );
479 found
480 } else {
481 prs
482 };
483 let sorted = classify(&repo, &numbers)?;
484 let mut results = Vec::new();
485 for number in sorted.prs {
486 results.push(review::resume_pr(
487 &agents,
488 &cfg,
489 &repo,
490 number,
491 next_actor.as_deref(),
492 ));
493 }
494 for number in sorted.issues {
497 match repo.open_pr_for_issue(number) {
498 Some(pr) => {
499 log!("#{number} is an issue; continuing its open PR {}", pr.url);
500 results.push(review::resume_pr(
501 &agents,
502 &cfg,
503 &repo,
504 pr.number,
505 next_actor.as_deref(),
506 ));
507 }
508 None => logwarn!(
509 "#{number} is an issue with no open pull request. Use `spar run {number}` \
510 to implement it."
511 ),
512 }
513 }
514 if results.is_empty() {
515 return Ok(0);
516 }
517 Ok(report(&results, &cfg))
518 }
519 }
520}
521
522#[derive(Debug, Default, Clone)]
527struct Overrides {
528 max_rounds: Option<u32>,
529 auto_merge: Option<bool>,
530 keep_worktrees: Option<bool>,
531 worktrees: Option<bool>,
532 close_skipped: Option<bool>,
533 absorb: Option<u32>,
534}
535
536impl From<&LoopFlags> for Overrides {
537 fn from(flags: &LoopFlags) -> Self {
538 Self {
539 max_rounds: flags.max_rounds,
540 auto_merge: flags.auto_merge.then_some(true),
541 keep_worktrees: flags.keep_worktrees.then_some(true),
542 worktrees: None,
543 close_skipped: None,
544 absorb: flags.absorb,
545 }
546 }
547}
548
549fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
550 let mut cfg = config::load(common.config.as_deref())?;
551
552 if let Some(first) = &common.first {
553 if !cfg.has_agent(first) {
554 bail!("--first must be one of: {}", cfg.agent_names().join(", "));
555 }
556 cfg.first_implementor = first.clone();
557 }
558 if let Some(base) = &common.base {
559 cfg.loop_cfg.base_branch = base.clone();
560 }
561 if let Some(min) = common.min_number {
562 cfg.loop_cfg.min_number = min;
563 }
564 if let Some(extra) = common.instructions.as_deref().map(str::trim) {
568 if !extra.is_empty() {
569 let standing = cfg.loop_cfg.instructions.trim();
570 cfg.loop_cfg.instructions = if standing.is_empty() {
571 extra.to_string()
572 } else {
573 format!("{standing}\n{extra}")
574 };
575 }
576 }
577 if let Some(over) = overrides {
578 if let Some(v) = over.max_rounds {
579 if v == 0 {
580 bail!("--max-rounds must be at least 1");
581 }
582 cfg.loop_cfg.max_rounds = v;
583 }
584 if let Some(v) = over.auto_merge {
585 cfg.loop_cfg.auto_merge = v;
586 }
587 if let Some(v) = over.keep_worktrees {
588 cfg.loop_cfg.keep_worktrees = v;
589 }
590 if let Some(v) = over.worktrees {
591 cfg.loop_cfg.worktrees = v;
592 }
593 if let Some(v) = over.close_skipped {
594 cfg.loop_cfg.close_skipped = v;
595 }
596 if let Some(v) = over.absorb {
597 cfg.loop_cfg.absorb_new_issues = v;
598 }
599 }
600
601 let repo = Repo::open(&common.repo, &cfg)?;
602 if common.base.is_none() {
603 cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
604 }
605
606 let agents = agent::build(&cfg)?;
607 if let Some(warning) = agent::correlation_warning(&agents) {
608 logging::warn(warning);
609 }
610
611 for stale in repo.prune_worktrees(false) {
613 let what = if stale.starts_with("branch ") {
614 stale
615 } else {
616 format!("worktree {stale}")
617 };
618 logdim!("cleaned up finished {what}");
619 }
620
621 log!("repo {} base {}", repo.root().display(), cfg.base_branch());
622 log!(
623 "agents: {}",
624 agents
625 .iter()
626 .map(|a| format!("{}={}", a.name(), a.spec.describe()))
627 .collect::<Vec<_>>()
628 .join(", ")
629 );
630 Ok((cfg, repo, agents))
631}
632
633fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
634 if !given.is_empty() {
635 if min_number > 0 {
637 let below: Vec<String> = given
638 .iter()
639 .filter(|n| **n < min_number)
640 .map(|n| format!("#{n}"))
641 .collect();
642 if !below.is_empty() {
643 logdim!(
644 "{} below the #{min_number} floor, taking them because you named them",
645 below.join(", ")
646 );
647 }
648 }
649 return Ok(given);
650 }
651 let found = repo.list_open_issues(limit, min_number)?;
652 if found.is_empty() {
653 log!("no open issues");
654 return Ok(found);
655 }
656 log!(
657 "no issues given, taking {} open: {}",
658 found.len(),
659 found
660 .iter()
661 .map(|n| format!("#{n}"))
662 .collect::<Vec<_>>()
663 .join(", ")
664 );
665 Ok(found)
666}
667
668#[derive(Debug, Default)]
674struct Sorted {
675 issues: Vec<i64>,
676 prs: Vec<i64>,
677}
678
679fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
680 let mut sorted = Sorted::default();
681 for number in numbers {
682 match repo.item_kind(*number)? {
683 ItemKind::Issue => sorted.issues.push(*number),
684 ItemKind::Pr => sorted.prs.push(*number),
685 }
686 }
687 if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
688 log!(
689 "{} issue(s) and {} pull request(s) given",
690 sorted.issues.len(),
691 sorted.prs.len()
692 );
693 }
694 Ok(sorted)
695}
696
697fn make_plan(
698 agents: &[Agent],
699 cfg: &Config,
700 repo: &Repo,
701 issues: &[Issue],
702 plan_out: &Path,
703) -> Result<Plan> {
704 let plan = triage::triage(agents, cfg, repo, issues)?;
705
706 std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
707 .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
708 log!("plan written to {}", plan_out.display());
709
710 for item in &plan.order {
711 log!(
712 " do #{} [{}/{}] {}",
713 item.issue,
714 item.complexity,
715 item.risk,
716 item.title
717 );
718 }
719 for item in &plan.skipped {
720 if item.tracker {
721 log!(
722 " hold #{} (both reviewers: tracks work filed elsewhere)",
723 item.issue
724 );
725 } else {
726 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
727 }
728 }
729 for item in &plan.contested {
730 log!(" ?? #{} contested, parked for you to decide", item.issue);
731 }
732 Ok(plan)
733}
734
735fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
745 for item in &plan.skipped {
746 let body = review::skip_comment(item, &repo.style);
747 let close = cfg.loop_cfg.close_skipped && !item.tracker;
748 let outcome = if close {
749 repo.close_issue(item.issue, &body)
750 } else {
751 repo.comment_issue(item.issue, &body)
752 };
753 match outcome {
754 Ok(()) if close => log!(" closed #{}", item.issue),
755 Ok(()) if item.tracker => {
756 log!(
757 " left #{} open, it tracks work filed elsewhere",
758 item.issue
759 )
760 }
761 Ok(()) => {}
762 Err(e) => logdim!("could not update #{}: {e}", item.issue),
763 }
764 }
765}
766
767fn cmd_scrub_filter() -> Result<i32> {
772 let mut input = String::new();
773 std::io::stdin()
774 .read_to_string(&mut input)
775 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
776 let out = style::scrub(&input, &crate::repo::style_from_env());
777 let mut stdout = std::io::stdout();
778 stdout
779 .write_all(out.as_bytes())
780 .and_then(|_| stdout.write_all(b"\n"))
781 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
782 Ok(0)
783}
784
785fn cmd_clean(
786 repo_path: &Path,
787 config_path: Option<&Path>,
788 all: bool,
789 pr_state: bool,
790) -> Result<i32> {
791 let cfg = config::load(config_path)?;
792 let repo = Repo::open(repo_path, &cfg)?;
793 let mut removed = repo.prune_worktrees(all);
794 removed.extend(repo.prune_state());
795 if pr_state {
796 removed.extend(repo.prune_pr_state(None));
797 }
798 if removed.is_empty() {
799 println!("nothing to clean");
800 } else {
801 for item in removed {
802 println!("removed {item}");
803 }
804 }
805 Ok(0)
806}
807
808fn cmd_post(
810 prs: &[i64],
811 repo_path: &Path,
812 config_path: Option<&Path>,
813 file: Option<&Path>,
814 dry_run: bool,
815) -> Result<i32> {
816 let cfg = config::load(config_path)?;
817 let repo = Repo::open(repo_path, &cfg)?;
818
819 if file.is_some() && prs.len() > 1 {
820 bail!("--file posts one review, so give it one pull request number");
821 }
822
823 let mut failed = false;
824 for number in prs {
825 let text = match file {
826 Some(path) => std::fs::read_to_string(path)
827 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
828 None => match repo.read_pending_comment(*number) {
829 Some(text) => text,
830 None => {
831 logging::error(format!(
832 "no saved review for PR #{number}. `spar review {number} --dry-run` \
833 produces one, or pass --file."
834 ));
835 failed = true;
836 continue;
837 }
838 },
839 };
840 if text.trim().is_empty() {
841 logging::error(format!("the saved review for PR #{number} is empty"));
842 failed = true;
843 continue;
844 }
845 if dry_run {
846 println!("\n{}\n", text.trim());
847 log!("would post the above to PR #{number}");
848 continue;
849 }
850 match repo.comment_pr(*number, &text) {
853 Ok(()) => log!("posted to PR #{number}"),
854 Err(e) => {
855 logging::error(format!("could not post to PR #{number}: {e}"));
856 failed = true;
857 }
858 }
859 }
860 Ok(if failed { 1 } else { 0 })
861}
862
863fn cmd_init_update(out: &Path) -> Result<i32> {
869 let text = std::fs::read_to_string(out)
870 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
871 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
874
875 let unset = config::unmentioned_options(&text);
876 if unset.is_empty() {
877 println!("{} already mentions every setting.", out.display());
878 return Ok(0);
879 }
880
881 let mut block = String::new();
882 if !text.ends_with('\n') {
883 block.push('\n');
884 }
885 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
886 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
887 let mut section = "";
888 for option in &unset {
889 if option.section != section {
890 section = option.section;
891 block.push_str(&format!("# [{section}]\n"));
892 }
893 block.push_str(&format!("# {} = {}\n", option.key, option.default));
894 }
895
896 use std::io::Write;
897 std::fs::OpenOptions::new()
898 .append(true)
899 .open(out)
900 .and_then(|mut f| f.write_all(block.as_bytes()))
901 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
902
903 println!(
904 "added {} setting(s) to {} as comments",
905 unset.len(),
906 out.display()
907 );
908 Ok(0)
909}
910
911fn cmd_init(out: &Path, force: bool) -> Result<i32> {
912 if out.exists() && !force {
913 logging::error(format!(
914 "{} already exists. `--update` appends any settings it does not mention, \
915 `--force` overwrites it.",
916 out.display()
917 ));
918 return Ok(1);
919 }
920
921 let presets = config::available_presets();
922 if presets.is_empty() {
923 bail!("no presets available, which should be impossible in a released build");
924 }
925
926 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
927 for name in &presets {
928 let raw = config::load_preset(name)?;
929 let mut spec: config::AgentSpec = match raw
933 .as_table()
934 .cloned()
935 .ok_or_else(|| spar_err!("not a table"))
936 .and_then(|t| {
937 toml::Value::Table(t)
938 .try_into()
939 .map_err(|e| spar_err!("{e}"))
940 }) {
941 Ok(spec) => spec,
942 Err(e) => {
943 println!(" BROKEN {name:10} {}", e.first_line());
944 continue;
945 }
946 };
947 spec.name = name.clone();
948 match Agent::new(spec.clone()).resolve_bin() {
949 Ok(path) => {
950 println!(" found {name:10} {}", path.display());
951 found.push((name.clone(), path.to_path_buf(), spec));
952 }
953 Err(_) => println!(" missing {name}"),
954 }
955 }
956
957 if found.len() < 2 {
958 logging::error(format!(
959 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
960 presets as a reference.",
961 found.len(),
962 out.display()
963 ));
964 return Ok(1);
965 }
966
967 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
969 if found.len() > 2 {
970 log!(
971 "{} agents available, picking {} and {}. Edit {} to change.",
972 found.len(),
973 chosen[0].0,
974 chosen[1].0,
975 out.display()
976 );
977 }
978
979 let mut text = String::from(
980 "# Generated by `spar init`. Each agent inherits a command template from a\n\
981 # built in preset; anything set here overrides it.\n\
982 #\n\
983 # Commented lines are the other options, each with a working value.\n\
984 # Uncomment one to change it.\n\n",
985 );
986 for (name, _, spec) in &chosen {
987 text.push_str(&agent_block(name, spec));
988 }
989 text.push_str(&settings_block(&chosen[0].0));
990
991 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
992 println!("\nwrote {}", out.display());
993 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
994 Ok(0)
995}
996
997fn report_fallback(agent: &Agent) {
1003 let Some(backup) = agent.fallback() else {
1004 return;
1005 };
1006 match backup.resolve_bin() {
1007 Ok(bin) => println!(
1008 " fallback {} ({})",
1009 bin.display(),
1010 backup.spec.describe()
1011 ),
1012 Err(_) => println!(
1013 " fallback {} not found, so it will not stand in. Set {} to its path.",
1014 backup.program(),
1015 backup.env_key()
1016 ),
1017 }
1018}
1019
1020type Setting = (bool, &'static str, &'static str);
1031
1032const LOOP_OPTIONS: &[Setting] = &[
1033 (
1034 false,
1035 "max_rounds",
1036 "review rounds ONE invocation may spend. Resuming grants a fresh budget, so this is not a lifetime cap on a PR.",
1037 ),
1038 (
1039 false,
1040 "auto_merge",
1041 "off on purpose: two models agreeing is not the same as being right",
1042 ),
1043 (false, "first_implementor", ""),
1044 (false, "worktrees", "false works in the main checkout"),
1045 (
1046 false,
1047 "close_skipped",
1048 "close an issue both reviewers declined",
1049 ),
1050 (
1051 false,
1052 "followups",
1053 "issues | local | none. local writes .spar/followups.md, not the tracker",
1054 ),
1055 (
1056 true,
1057 "file_non_blocking",
1058 "a suggestion is not a tracker item",
1059 ),
1060 (
1061 true,
1062 "max_followups",
1063 "backstop on what one run can spawn",
1064 ),
1065 (
1066 true,
1067 "keep_worktrees",
1068 "true leaves them behind to inspect",
1069 ),
1070 (
1071 true,
1072 "min_number",
1073 "ignore anything numbered below this when picking for itself. 0 is no floor.",
1074 ),
1075 (
1076 true,
1077 "parallel_triage",
1078 "false asks the agents one at a time",
1079 ),
1080 (
1081 true,
1082 "absorb_new_issues",
1083 "waves of newly filed follow-ups to fold back into this run. Costs more.",
1084 ),
1085 (true, "file_nits", "true files nits as issues too"),
1086 (
1087 true,
1088 "base_branch",
1089 "only a fallback; origin/HEAD wins when it resolves",
1090 ),
1091 (
1092 true,
1093 "branch_prefix",
1094 "e.g. \"spar/\" to namespace the branches spar creates",
1095 ),
1096 (true, "state_store", "local | pr | both"),
1097 (
1098 true,
1099 "drafts",
1100 "never | until_approved | always. until_approved opens a draft and marks it ready when the review converges, which is what a draft was saying while two agents were still arguing about it.",
1101 ),
1102 (
1103 true,
1104 "instructions",
1105 "extra instructions handed to both agents with every request, for what this repository always wants that spar has no setting for. --instructions adds to it for one run.",
1106 ),
1107 (
1108 true,
1109 "max_issue_chars",
1110 "most of one issue body a prompt carries. Sized so nothing a person wrote is cut, and a cut is said out loud when it happens.",
1111 ),
1112 (
1113 true,
1114 "max_triage_chars",
1115 "most every issue body together may add to one triage prompt. Past it, whole issues wait for the next run rather than all of them losing their tails.",
1116 ),
1117];
1118
1119const STYLE_OPTIONS: &[Setting] = &[
1120 (false, "ban_em_dash", ""),
1121 (false, "ban_ai_attribution", ""),
1122 (
1123 false,
1124 "terse",
1125 "hold model prose to a length budget. false removes the valves entirely",
1126 ),
1127 (
1128 true,
1129 "pr_comments",
1130 "outcome | rounds | none. How much of its own working spar narrates into a PR thread. none never comments at all.",
1131 ),
1132 (
1133 true,
1134 "max_title_chars",
1135 "a finding, issue, or PR title. Never ellipsised",
1136 ),
1137 (
1138 true,
1139 "max_summary_chars",
1140 "a one line verdict or refutation",
1141 ),
1142 (
1143 true,
1144 "max_detail_chars",
1145 "a blocking finding, in the PR thread",
1146 ),
1147 (true, "max_body_chars", "a PR body"),
1148 (
1149 true,
1150 "max_issue_body_chars",
1151 "a filed issue's body. Far larger on purpose: an issue is picked up cold. Fenced code blocks in one are never truncated and never count against this.",
1152 ),
1153];
1154
1155fn settings_block(first_implementor: &str) -> String {
1160 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1161 .into_iter()
1162 .map(|option| (option.key, option.default))
1163 .collect();
1164 let value = |key: &str| match key {
1167 "first_implementor" => format!("\"{first_implementor}\""),
1168 other => defaults.get(other).cloned().unwrap_or_default(),
1169 };
1170
1171 let mut out = String::from("[loop]\n");
1172 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1173 out.push_str(concat!(
1174 "\n[loop.effort_schedule]\n",
1175 "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1176 "# these are examples rather than defaults. Left out, each agent uses\n",
1177 "# the effort its own block asked for.\n",
1178 "# round_1 = \"high\" # the deep first review\n",
1179 "# rest = \"low\" # later rounds only see a small delta\n\n",
1180 ));
1181 out.push_str("[style]\n");
1182 out.push_str(&option_lines(STYLE_OPTIONS, &value));
1183 out
1184}
1185
1186fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1190 let rows: Vec<(String, String)> = options
1191 .iter()
1192 .map(|(commented, key, note)| {
1193 let lead = if *commented { "# " } else { "" };
1194 (format!("{lead}{key} = {}", value(key)), note.to_string())
1195 })
1196 .collect();
1197 aligned(&rows)
1198}
1199
1200fn aligned(rows: &[(String, String)]) -> String {
1208 const WIDTH: usize = 78;
1209
1210 let column = rows
1211 .iter()
1212 .map(|(assignment, _)| assignment.chars().count())
1213 .max()
1214 .unwrap_or(0)
1215 + 2;
1216
1217 let mut out = String::new();
1218 for (assignment, note) in rows {
1219 if note.is_empty() {
1220 out.push_str(assignment);
1221 out.push('\n');
1222 continue;
1223 }
1224 let mut first = true;
1225 let mut line = String::new();
1226 for word in note.split_whitespace() {
1227 let would_be = column + 2 + line.chars().count() + 1 + word.chars().count();
1228 if !line.is_empty() && would_be > WIDTH {
1229 out.push_str(¬ed(assignment, &line, column, &mut first));
1230 line.clear();
1231 }
1232 if !line.is_empty() {
1233 line.push(' ');
1234 }
1235 line.push_str(word);
1236 }
1237 if !line.is_empty() {
1238 out.push_str(¬ed(assignment, &line, column, &mut first));
1239 }
1240 }
1241 out
1242}
1243
1244fn noted(assignment: &str, note: &str, column: usize, first: &mut bool) -> String {
1247 let lead = if *first {
1248 let pad = column.saturating_sub(assignment.chars().count());
1249 format!("{assignment}{}", " ".repeat(pad))
1250 } else {
1251 " ".repeat(column)
1252 };
1253 *first = false;
1254 format!("{lead}# {note}\n")
1255}
1256
1257type Probe = Box<dyn Fn() -> Result<String>>;
1260
1261fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1268 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1269
1270 let offered: Vec<(&str, &[String])> = [
1280 ("model ", spec.models.as_slice()),
1281 ("effort", spec.efforts.as_slice()),
1282 ]
1283 .into_iter()
1284 .filter(|(_, choices)| !choices.is_empty())
1285 .collect();
1286
1287 if !offered.is_empty() {
1288 let named: Vec<&str> = offered.iter().map(|(key, _)| key.trim()).collect();
1289 out.push_str(&format!(
1290 "# Omit {} to use the CLI's own default.\n",
1291 named.join(" or ")
1292 ));
1293
1294 let rows: Vec<(String, String)> = offered
1298 .iter()
1299 .map(|(key, choices)| {
1300 let note = if choices.len() > 1 {
1301 choices.join(" | ")
1302 } else {
1303 String::new()
1304 };
1305 (format!("# {key} = \"{}\"", choices[0]), note)
1306 })
1307 .collect();
1308 out.push_str(&aligned(&rows));
1309 }
1310
1311 if let Some(note) = &spec.options_note {
1312 out.push_str(&wrap_comment(note));
1313 }
1314 let backup = if name == "cursor" { "gemini" } else { "cursor" };
1317 out.push_str("# A stand in for when this CLI refuses, stalls, or runs out of quota.\n");
1318 out.push_str("# It answers in place of this agent, never alongside it.\n");
1319 out.push_str(&format!(
1320 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1321 ));
1322 out.push('\n');
1323 out
1324}
1325
1326fn wrap_comment(text: &str) -> String {
1328 const WIDTH: usize = 76;
1329 let mut out = String::new();
1330 let mut line = String::from("#");
1331 for word in text.split_whitespace() {
1332 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1333 out.push_str(&line);
1334 out.push('\n');
1335 line = String::from("#");
1336 }
1337 line.push(' ');
1338 line.push_str(word);
1339 }
1340 if line.len() > 1 {
1341 out.push_str(&line);
1342 out.push('\n');
1343 }
1344 out
1345}
1346
1347fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1348 let mut ok = true;
1349
1350 let probes: Vec<(&str, Probe)> = vec![
1351 (
1352 "git",
1353 Box::new(|| {
1354 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1355 .map(|s| first_line(&s))
1356 }),
1357 ),
1358 (
1359 "gh",
1360 Box::new(|| {
1361 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1362 .map(|s| first_line(&s))
1363 }),
1364 ),
1365 (
1366 "gh auth",
1367 Box::new(|| {
1368 let out = proc::exec(
1369 &["gh".into(), "auth".into(), "status".into()],
1370 &ExecOpts::new().check(false).timeout_secs(60),
1371 )?;
1372 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1373 if out.ok() {
1374 Ok(first_line(&text))
1375 } else {
1376 Err(spar_err!("not authenticated. Run `gh auth login`."))
1377 }
1378 }),
1379 ),
1380 ];
1381
1382 for (label, probe) in probes {
1383 match probe() {
1384 Ok(detail) => println!(" ok {label:12} {detail}"),
1385 Err(e) => {
1386 println!(" FAIL {label:12} {}", e.first_line());
1387 ok = false;
1388 }
1389 }
1390 }
1391
1392 let found = config::find_config(config_path)?;
1393 let Some(path) = found else {
1394 println!("\n no spar.toml found. Run `spar init` to generate one.");
1395 println!(
1396 " presets available: {}",
1397 config::available_presets().join(", ")
1398 );
1399 return Ok(if ok { 0 } else { 1 });
1400 };
1401
1402 println!("\n config: {}", path.display());
1403 let cfg = match config::load(Some(&path)) {
1404 Ok(cfg) => cfg,
1405 Err(e) => {
1406 println!(" FAIL config {e}");
1407 return Ok(1);
1408 }
1409 };
1410
1411 let mut resolved = Vec::new();
1414 for spec in &cfg.agents {
1415 let agent = Agent::new(spec.clone());
1416 match agent.resolve_bin() {
1417 Ok(bin) => {
1418 println!(
1419 " ok {:12} {} ({})",
1420 spec.name,
1421 bin.display(),
1422 spec.describe()
1423 );
1424 report_fallback(&agent);
1425 resolved.push(agent);
1426 }
1427 Err(e) => {
1428 println!(" FAIL {:12} {}", spec.name, e.first_line());
1429 ok = false;
1430 }
1431 }
1432 }
1433
1434 if resolved.len() == cfg.agents.len() {
1435 if let Some(warning) = agent::correlation_warning(&resolved) {
1436 println!("\n WARNING {warning}");
1437 }
1438 }
1439
1440 println!(
1441 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1442 cfg.loop_cfg.max_rounds,
1443 cfg.loop_cfg.auto_merge,
1444 cfg.loop_cfg.worktrees,
1445 cfg.loop_cfg.followups,
1446 cfg.style.terse
1447 );
1448 if let Ok(text) = std::fs::read_to_string(&path) {
1452 let unset = config::unmentioned_options(&text);
1453 if !unset.is_empty() {
1454 println!(
1455 "\n {} setting(s) this config does not mention, all at their defaults:",
1456 unset.len()
1457 );
1458 for option in &unset {
1459 println!(
1460 " [{}] {} = {}",
1461 option.section, option.key, option.default
1462 );
1463 }
1464 println!(
1465 " `spar init --update {}` appends them as comments.",
1466 path.display()
1467 );
1468 }
1469 }
1470
1471 println!(
1472 "{}",
1473 if ok {
1474 "\nready"
1475 } else {
1476 "\nmissing prerequisites"
1477 }
1478 );
1479 Ok(if ok { 0 } else { 1 })
1480}
1481
1482fn first_line(text: &str) -> String {
1483 text.trim().lines().next().unwrap_or("").trim().to_string()
1484}
1485
1486fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1491 println!("\n{}", "=".repeat(60));
1492 for r in results {
1493 println!(
1494 "#{:<5} {:<10} rounds={} {}",
1495 r.issue,
1496 r.status.to_string(),
1497 r.rounds,
1498 r.pr.as_deref().unwrap_or("")
1499 );
1500 for note in &r.notes {
1501 println!(" {}", first_line(note));
1502 }
1503 for url in &r.filed {
1504 println!(" filed {url}");
1505 }
1506 for dispute in &r.disputes {
1507 println!(" disputed: {}", dispute.title);
1508 }
1509 }
1510 println!("{}", "=".repeat(60));
1511
1512 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1513 println!("\nApproved PRs are waiting on you to merge.");
1514 }
1515 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1516 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1517 println!(
1518 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1519 Set followups = \"issues\" to file them."
1520 );
1521 }
1522 if results.iter().all(IssueRun::succeeded) {
1523 0
1524 } else {
1525 1
1526 }
1527}
1528
1529#[cfg(test)]
1530mod tests {
1531 use super::*;
1532 use clap::CommandFactory;
1533
1534 #[test]
1535 fn the_parser_is_internally_consistent() {
1536 Cli::command().debug_assert();
1537 }
1538
1539 #[test]
1540 fn quiet_is_accepted_before_or_after_the_subcommand() {
1541 for argv in [
1542 vec!["spar", "--quiet", "run", "42"],
1543 vec!["spar", "run", "42", "--quiet"],
1544 vec!["spar", "resume", "--quiet"],
1545 vec!["spar", "init", "-q"],
1546 ] {
1547 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1548 }
1549 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1550 }
1551
1552 #[test]
1553 fn several_issue_numbers_are_accepted() {
1554 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1555 match cli.command {
1556 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1557 other => panic!("{other:?}"),
1558 }
1559 }
1560
1561 #[test]
1562 fn issue_numbers_and_flags_can_be_interleaved() {
1563 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1564 match cli.command {
1565 Command::Run {
1566 issues, loop_flags, ..
1567 } => {
1568 assert_eq!(vec![42, 51], issues);
1569 assert!(loop_flags.auto_merge);
1570 }
1571 other => panic!("{other:?}"),
1572 }
1573 }
1574
1575 #[test]
1576 fn every_command_that_reads_a_config_accepts_one() {
1577 for argv in [
1578 vec!["spar", "run", "42"],
1579 vec!["spar", "triage"],
1580 vec!["spar", "resume"],
1581 vec!["spar", "clean"],
1582 vec!["spar", "doctor"],
1583 ] {
1584 let mut full = argv.clone();
1585 full.extend(["--config", "other.toml"]);
1586 let cli = Cli::parse_from(&full);
1587 let config = match cli.command {
1588 Command::Run { common, .. }
1589 | Command::Triage { common, .. }
1590 | Command::Resume { common, .. } => common.config,
1591 Command::Clean { config, .. } | Command::Doctor { config } => config,
1592 other => panic!("{other:?}"),
1593 };
1594 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1595 }
1596 }
1597
1598 #[test]
1599 fn auto_merge_is_off_unless_asked_for() {
1600 let cli = Cli::parse_from(["spar", "run"]);
1601 match cli.command {
1602 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1603 other => panic!("{other:?}"),
1604 }
1605 }
1606
1607 #[test]
1610 fn every_command_that_reads_a_config_takes_instructions() {
1611 for cmd in ["run", "triage", "resume", "review"] {
1612 let argv = vec!["spar", cmd, "7", "--instructions", "Do not wait for CI."];
1613 let parsed = Cli::parse_from(&argv);
1614 let common = match parsed.command {
1615 Command::Run { common, .. }
1616 | Command::Triage { common, .. }
1617 | Command::Resume { common, .. }
1618 | Command::Review { common, .. } => common,
1619 other => panic!("{other:?}"),
1620 };
1621 assert_eq!(
1622 Some("Do not wait for CI."),
1623 common.instructions.as_deref(),
1624 "{cmd}"
1625 );
1626 }
1627 }
1628
1629 #[test]
1630 fn the_two_close_skipped_flags_are_mutually_exclusive() {
1631 assert!(
1632 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1633 );
1634 }
1635
1636 #[test]
1639 fn close_skipped_is_offered_only_where_it_means_something() {
1640 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1641 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1642 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1643 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1644 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1645 }
1646
1647 #[test]
1648 fn the_close_skipped_pair_resolves_to_a_tristate() {
1649 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1650 Command::Run { triage_flags, .. } => {
1651 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1652 (true, _) => Some(true),
1653 (_, true) => Some(false),
1654 _ => None,
1655 }
1656 }
1657 other => panic!("{other:?}"),
1658 };
1659 assert_eq!(None, read(&["spar", "run"]));
1660 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1661 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1662 }
1663
1664 #[test]
1665 fn the_default_limit_is_twenty() {
1666 let cli = Cli::parse_from(["spar", "run"]);
1667 match cli.command {
1668 Command::Run { common, .. } => assert_eq!(20, common.limit),
1669 other => panic!("{other:?}"),
1670 }
1671 }
1672
1673 #[test]
1674 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1675 assert!(matches!(
1676 Cli::parse_from(["spar", "scrub-filter"]).command,
1677 Command::ScrubFilter
1678 ));
1679 let help = Cli::command().render_long_help().to_string();
1680 assert!(
1681 !help.contains("scrub-filter"),
1682 "it is plumbing, not a command"
1683 );
1684 }
1685
1686 #[test]
1687 fn review_takes_pr_numbers_and_a_dry_run() {
1688 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1689 match cli.command {
1690 Command::Review { items, dry_run, .. } => {
1691 assert_eq!(vec![101, 102], items);
1692 assert!(dry_run);
1693 }
1694 other => panic!("{other:?}"),
1695 }
1696 }
1697
1698 #[test]
1699 fn review_posts_unless_told_not_to() {
1700 match Cli::parse_from(["spar", "review", "101"]).command {
1701 Command::Review { dry_run, .. } => assert!(!dry_run),
1702 other => panic!("{other:?}"),
1703 }
1704 }
1705
1706 #[test]
1707 fn review_with_no_numbers_is_allowed() {
1708 match Cli::parse_from(["spar", "review"]).command {
1709 Command::Review { items, .. } => assert!(items.is_empty()),
1710 other => panic!("{other:?}"),
1711 }
1712 }
1713
1714 #[test]
1715 fn review_takes_its_own_round_budget() {
1716 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1717 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1718 other => panic!("{other:?}"),
1719 }
1720 }
1721
1722 #[test]
1723 fn resume_takes_a_next_override() {
1724 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1725 match cli.command {
1726 Command::Resume {
1727 prs, next_actor, ..
1728 } => {
1729 assert_eq!(vec![108], prs);
1730 assert_eq!(Some("codex".to_string()), next_actor);
1731 }
1732 other => panic!("{other:?}"),
1733 }
1734 }
1735}
1736
1737#[cfg(test)]
1738mod absorb_tests {
1739 use super::*;
1740
1741 #[test]
1742 fn absorb_is_off_unless_asked_for() {
1743 match Cli::parse_from(["spar", "run"]).command {
1744 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1745 other => panic!("{other:?}"),
1746 }
1747 }
1748
1749 #[test]
1750 fn absorb_takes_a_wave_count() {
1751 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1752 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1753 other => panic!("{other:?}"),
1754 }
1755 }
1756
1757 #[test]
1758 fn absorb_is_only_offered_where_issues_are_worked() {
1759 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1760 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1761 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1762 }
1763}
1764
1765#[cfg(test)]
1766mod min_number_tests {
1767 use super::*;
1768
1769 fn read(argv: &[&str]) -> Option<i64> {
1770 match Cli::parse_from(argv).command {
1771 Command::Run { common, .. }
1772 | Command::Triage { common, .. }
1773 | Command::Resume { common, .. }
1774 | Command::Review { common, .. } => common.min_number,
1775 other => panic!("{other:?}"),
1776 }
1777 }
1778
1779 #[test]
1780 fn there_is_no_floor_unless_one_is_asked_for() {
1781 assert_eq!(None, read(&["spar", "run"]));
1782 }
1783
1784 #[test]
1785 fn every_command_that_picks_for_itself_accepts_a_floor() {
1786 for cmd in ["run", "triage", "resume", "review"] {
1787 assert_eq!(
1788 Some(480),
1789 read(&["spar", cmd, "--min-number", "480"]),
1790 "{cmd}"
1791 );
1792 }
1793 }
1794}
1795
1796#[cfg(test)]
1797mod settings_block_tests {
1798 use super::*;
1799
1800 fn written(line: &str) -> String {
1803 let after = line.split_once('=').expect("an assignment").1;
1804 let mut quoted = false;
1805 for (i, c) in after.char_indices() {
1806 match c {
1807 '"' => quoted = !quoted,
1808 '#' if !quoted => return after[..i].trim().to_string(),
1809 _ => {}
1810 }
1811 }
1812 after.trim().to_string()
1813 }
1814
1815 fn line_for(text: &str, key: &str) -> String {
1816 text.lines()
1817 .find(|l| {
1818 let bare = l.trim_start().trim_start_matches('#').trim_start();
1819 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1820 })
1821 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
1822 .to_string()
1823 }
1824
1825 #[test]
1832 fn every_value_it_offers_is_the_default_it_actually_has() {
1833 let text = settings_block("claude");
1834 for option in config::known_options() {
1835 if option.section == "loop.effort_schedule" {
1839 continue;
1840 }
1841 let line = line_for(&text, &option.key);
1842 assert_eq!(
1843 option.default,
1844 written(&line),
1845 "the generated config offers `{}`, but the default is {}",
1846 line.trim(),
1847 option.default
1848 );
1849 }
1850 }
1851
1852 #[test]
1856 fn it_offers_every_option_the_parser_knows_about() {
1857 let text = settings_block("claude");
1858 let missing: Vec<String> = config::unmentioned_options(&text)
1859 .into_iter()
1860 .map(|o| format!("[{}] {}", o.section, o.key))
1861 .collect();
1862 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
1863 }
1864
1865 #[test]
1869 fn every_option_it_offers_can_be_uncommented_and_still_load() {
1870 let mut text = String::from(
1871 "[agents.claude]\ncommand = [\"claude\"]\n\n\
1872 [agents.codex]\ncommand = [\"codex\"]\n\n",
1873 );
1874 for line in settings_block("claude").lines() {
1875 text.push_str(uncomment(line).unwrap_or(line));
1876 text.push('\n');
1877 }
1878 let cfg = config::parse(&text).expect("a config of its own suggestions");
1879 assert_eq!("claude", cfg.first_implementor);
1880 }
1881
1882 fn uncomment(line: &str) -> Option<&str> {
1885 let bare = line.trim_start().strip_prefix('#')?.trim_start();
1886 let key = bare.split_once('=')?.0.trim();
1889 let named = !key.is_empty()
1890 && key
1891 .chars()
1892 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1893 named.then_some(bare)
1894 }
1895
1896 #[test]
1897 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
1898 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
1899 }
1900
1901 #[test]
1904 fn a_wrapped_note_stays_in_its_column() {
1905 let text = settings_block("claude");
1906 let column = text
1907 .lines()
1908 .find(|l| l.starts_with("max_rounds"))
1909 .and_then(|l| l.find('#'))
1910 .expect("a note on max_rounds");
1911 let continuation = text
1912 .lines()
1913 .find(|l| l.starts_with(" ") && l.trim_start().starts_with('#'))
1914 .expect("a wrapped note");
1915 assert_eq!(Some(column), continuation.find('#'));
1916 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text}");
1917 }
1918}
1919
1920#[cfg(test)]
1921mod agent_block_tests {
1922 use super::*;
1923
1924 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
1925 let mut spec: config::AgentSpec =
1926 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
1927 .try_into()
1928 .expect("builds");
1929 spec.models = models.iter().map(|s| s.to_string()).collect();
1930 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
1931 spec
1932 }
1933
1934 #[test]
1938 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
1939 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
1940 assert!(!block.contains("..."), "{block}");
1941 assert!(!block.contains("effort"), "{block}");
1942 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
1943 }
1944
1945 #[test]
1949 fn the_header_names_only_the_options_that_follow() {
1950 assert!(agent_block("cursor", &spec(&["auto"], &[])).contains("Omit model to use"));
1951 assert!(
1952 agent_block("claude", &spec(&["fable"], &["high"])).contains("Omit model or effort")
1953 );
1954 }
1955
1956 #[test]
1959 fn a_preset_with_no_hints_still_writes_a_usable_block() {
1960 let block = agent_block("gemini", &spec(&[], &[]));
1961 assert!(!block.contains("..."), "{block}");
1962 assert!(!block.contains("Omit"), "{block}");
1963 assert!(
1964 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
1965 "{block}"
1966 );
1967 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
1969 }
1970
1971 #[test]
1976 fn a_long_list_of_choices_wraps_into_its_column() {
1977 let block = agent_block(
1978 "codex",
1979 &spec(
1980 &[
1981 "gpt-5.6-sol",
1982 "gpt-5.6-terra",
1983 "gpt-5.6-luna",
1984 "gpt-5.6-pro",
1985 ],
1986 &[
1987 "ultra", "max", "xhigh", "high", "medium", "low", "minimal", "none",
1988 ],
1989 ),
1990 );
1991 assert!(
1992 block.lines().all(|l| l.chars().count() <= 80),
1993 "a line runs off the edge:\n{block}"
1994 );
1995 for choice in ["gpt-5.6-pro", "minimal", "none"] {
1997 assert!(block.contains(choice), "{choice} was lost:\n{block}");
1998 }
1999 }
2000
2001 #[test]
2002 fn alternatives_are_listed_only_when_there_are_any() {
2003 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("# one | two"));
2004 let single = agent_block("b", &spec(&["only"], &[]));
2005 assert!(!single.contains('|'), "{single}");
2006 }
2007}