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!("\n# [{section}]\n"));
892 }
893 block.push('\n');
898 block.push_str(&wrap_comment(note_for(&option.key)));
899 block.push_str(&format!("# {} = {}\n", option.key, option.default));
900 }
901
902 use std::io::Write;
903 std::fs::OpenOptions::new()
904 .append(true)
905 .open(out)
906 .and_then(|mut f| f.write_all(block.as_bytes()))
907 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
908
909 println!(
910 "added {} setting(s) to {} as comments",
911 unset.len(),
912 out.display()
913 );
914 Ok(0)
915}
916
917fn cmd_init(out: &Path, force: bool) -> Result<i32> {
918 if out.exists() && !force {
919 logging::error(format!(
920 "{} already exists. `--update` appends any settings it does not mention, \
921 `--force` overwrites it.",
922 out.display()
923 ));
924 return Ok(1);
925 }
926
927 let presets = config::available_presets();
928 if presets.is_empty() {
929 bail!("no presets available, which should be impossible in a released build");
930 }
931
932 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
933 for name in &presets {
934 let raw = config::load_preset(name)?;
935 let mut spec: config::AgentSpec = match raw
939 .as_table()
940 .cloned()
941 .ok_or_else(|| spar_err!("not a table"))
942 .and_then(|t| {
943 toml::Value::Table(t)
944 .try_into()
945 .map_err(|e| spar_err!("{e}"))
946 }) {
947 Ok(spec) => spec,
948 Err(e) => {
949 println!(" BROKEN {name:10} {}", e.first_line());
950 continue;
951 }
952 };
953 spec.name = name.clone();
954 match Agent::new(spec.clone()).resolve_bin() {
955 Ok(path) => {
956 println!(" found {name:10} {}", path.display());
957 found.push((name.clone(), path.to_path_buf(), spec));
958 }
959 Err(_) => println!(" missing {name}"),
960 }
961 }
962
963 if found.len() < 2 {
964 logging::error(format!(
965 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
966 presets as a reference.",
967 found.len(),
968 out.display()
969 ));
970 return Ok(1);
971 }
972
973 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
975 if found.len() > 2 {
976 log!(
977 "{} agents available, picking {} and {}. Edit {} to change.",
978 found.len(),
979 chosen[0].0,
980 chosen[1].0,
981 out.display()
982 );
983 }
984
985 let mut text = String::from(
986 "# Generated by `spar init`. Each agent inherits a command template from a\n\
987 # built in preset; anything set here overrides it.\n\
988 #\n\
989 # Commented lines are the other options, each with a working value.\n\
990 # Uncomment one to change it.\n\n",
991 );
992 for (name, _, spec) in &chosen {
993 text.push_str(&agent_block(name, spec));
994 }
995 text.push_str(&settings_block(&chosen[0].0));
996
997 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
998 println!("\nwrote {}", out.display());
999 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1000 Ok(0)
1001}
1002
1003fn report_fallback(agent: &Agent) {
1009 let Some(backup) = agent.fallback() else {
1010 return;
1011 };
1012 match backup.resolve_bin() {
1013 Ok(bin) => println!(
1014 " fallback {} ({})",
1015 bin.display(),
1016 backup.spec.describe()
1017 ),
1018 Err(_) => println!(
1019 " fallback {} not found, so it will not stand in. Set {} to its path.",
1020 backup.program(),
1021 backup.env_key()
1022 ),
1023 }
1024}
1025
1026type Setting = (bool, &'static str, &'static str);
1037
1038const LOOP_OPTIONS: &[Setting] = &[
1039 (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."),
1040 (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."),
1041 (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1042 (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1043 (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1044 (false, "followups", "Where a follow-up goes. issues files them, local writes .spar/followups.md and leaves the tracker alone, none drops them."),
1045 (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."),
1046 (true, "max_followups", "Most follow-ups one run may record before it stops and says what it dropped. A backstop, not a target."),
1047 (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1048 (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."),
1049 (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1050 (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."),
1051 (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1052 (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1053 (true, "branch_prefix", "Namespace the branches spar creates, for example \"spar/\". Without it they are issue-N and pr-N."),
1054 (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1055 (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."),
1056 (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."),
1057 (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."),
1058 (true, "max_triage_chars", "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."),
1059];
1060
1061const STYLE_OPTIONS: &[Setting] = &[
1062 (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1063 (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1064 (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1065 (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."),
1066 (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1067 (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1068 (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1069 (true, "max_body_chars", "A pull request body."),
1070 (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."),
1071];
1072
1073fn settings_block(first_implementor: &str) -> String {
1078 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1079 .into_iter()
1080 .map(|option| (option.key, option.default))
1081 .collect();
1082 let value = |key: &str| match key {
1085 "first_implementor" => format!("\"{first_implementor}\""),
1086 other => defaults.get(other).cloned().unwrap_or_default(),
1087 };
1088
1089 let mut out = String::from("[loop]\n");
1090 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1091 out.push_str(concat!(
1092 "\n[loop.effort_schedule]\n",
1093 "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1094 "# these are examples rather than defaults. Left out, each agent uses\n",
1095 "# the effort its own block asked for.\n",
1096 "# round_1 = \"high\" # the deep first review\n",
1097 "# rest = \"low\" # later rounds only see a small delta\n\n",
1098 ));
1099 out.push_str("[style]\n");
1100 out.push_str(&option_lines(STYLE_OPTIONS, &value));
1101 out
1102}
1103
1104fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1108 let mut out = String::new();
1109 for (commented, key, note) in options {
1110 if !out.is_empty() {
1111 out.push('\n');
1112 }
1113 out.push_str(&wrap_comment(note));
1114 let lead = if *commented { "# " } else { "" };
1115 out.push_str(&format!("{lead}{key} = {}\n", value(key)));
1116 }
1117 out
1118}
1119
1120type Probe = Box<dyn Fn() -> Result<String>>;
1123
1124fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1131 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1132
1133 if let Some(extra) = &spec.options_note {
1145 if !spec.models.is_empty() || !spec.efforts.is_empty() {
1146 out.push('\n');
1147 out.push_str(&wrap_comment(extra));
1148 }
1149 }
1150 for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
1151 let Some(suggested) = choices.first() else {
1152 continue;
1153 };
1154 let mut note = format!("Omit {key} to use the CLI's own default.");
1155 if choices.len() > 1 {
1156 note.push_str(&format!(" One of: {}.", choices.join(" | ")));
1157 }
1158 out.push('\n');
1159 out.push_str(&wrap_comment(¬e));
1160 out.push_str(&format!("# {key} = \"{suggested}\"\n"));
1161 }
1162
1163 out.push('\n');
1167 out.push_str(&wrap_comment(
1168 "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
1169 never retried, so err long.",
1170 ));
1171 out.push_str(&format!("# timeout = {}\n", spec.timeout));
1172
1173 let backup = if name == "cursor" { "gemini" } else { "cursor" };
1176 out.push('\n');
1177 out.push_str(&wrap_comment(
1178 "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
1179 of this agent, never alongside it.",
1180 ));
1181 out.push_str(&format!(
1182 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1183 ));
1184
1185 out.push('\n');
1190 out.push_str(&wrap_comment(
1191 "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
1192 that has no preset.",
1193 ));
1194 out.push('\n');
1195 out
1196}
1197
1198fn note_for(key: &str) -> &'static str {
1204 LOOP_OPTIONS
1205 .iter()
1206 .chain(STYLE_OPTIONS)
1207 .find(|(_, name, _)| *name == key)
1208 .map(|(_, _, note)| *note)
1209 .unwrap_or("")
1210}
1211
1212fn wrap_comment(text: &str) -> String {
1214 const WIDTH: usize = 76;
1215 let mut out = String::new();
1216 let mut line = String::from("#");
1217 for word in text.split_whitespace() {
1218 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1219 out.push_str(&line);
1220 out.push('\n');
1221 line = String::from("#");
1222 }
1223 line.push(' ');
1224 line.push_str(word);
1225 }
1226 if line.len() > 1 {
1227 out.push_str(&line);
1228 out.push('\n');
1229 }
1230 out
1231}
1232
1233fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1234 let mut ok = true;
1235
1236 let probes: Vec<(&str, Probe)> = vec![
1237 (
1238 "git",
1239 Box::new(|| {
1240 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1241 .map(|s| first_line(&s))
1242 }),
1243 ),
1244 (
1245 "gh",
1246 Box::new(|| {
1247 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1248 .map(|s| first_line(&s))
1249 }),
1250 ),
1251 (
1252 "gh auth",
1253 Box::new(|| {
1254 let out = proc::exec(
1255 &["gh".into(), "auth".into(), "status".into()],
1256 &ExecOpts::new().check(false).timeout_secs(60),
1257 )?;
1258 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1259 if out.ok() {
1260 Ok(first_line(&text))
1261 } else {
1262 Err(spar_err!("not authenticated. Run `gh auth login`."))
1263 }
1264 }),
1265 ),
1266 ];
1267
1268 for (label, probe) in probes {
1269 match probe() {
1270 Ok(detail) => println!(" ok {label:12} {detail}"),
1271 Err(e) => {
1272 println!(" FAIL {label:12} {}", e.first_line());
1273 ok = false;
1274 }
1275 }
1276 }
1277
1278 let found = config::find_config(config_path)?;
1279 let Some(path) = found else {
1280 println!("\n no spar.toml found. Run `spar init` to generate one.");
1281 println!(
1282 " presets available: {}",
1283 config::available_presets().join(", ")
1284 );
1285 return Ok(if ok { 0 } else { 1 });
1286 };
1287
1288 println!("\n config: {}", path.display());
1289 let cfg = match config::load(Some(&path)) {
1290 Ok(cfg) => cfg,
1291 Err(e) => {
1292 println!(" FAIL config {e}");
1293 return Ok(1);
1294 }
1295 };
1296
1297 let mut resolved = Vec::new();
1300 for spec in &cfg.agents {
1301 let agent = Agent::new(spec.clone());
1302 match agent.resolve_bin() {
1303 Ok(bin) => {
1304 println!(
1305 " ok {:12} {} ({})",
1306 spec.name,
1307 bin.display(),
1308 spec.describe()
1309 );
1310 report_fallback(&agent);
1311 resolved.push(agent);
1312 }
1313 Err(e) => {
1314 println!(" FAIL {:12} {}", spec.name, e.first_line());
1315 ok = false;
1316 }
1317 }
1318 }
1319
1320 if resolved.len() == cfg.agents.len() {
1321 if let Some(warning) = agent::correlation_warning(&resolved) {
1322 println!("\n WARNING {warning}");
1323 }
1324 }
1325
1326 println!(
1327 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1328 cfg.loop_cfg.max_rounds,
1329 cfg.loop_cfg.auto_merge,
1330 cfg.loop_cfg.worktrees,
1331 cfg.loop_cfg.followups,
1332 cfg.style.terse
1333 );
1334 if let Ok(text) = std::fs::read_to_string(&path) {
1338 let unset = config::unmentioned_options(&text);
1339 if !unset.is_empty() {
1340 println!(
1341 "\n {} setting(s) this config does not mention, all at their defaults:",
1342 unset.len()
1343 );
1344 for option in &unset {
1345 println!(
1346 " [{}] {} = {}",
1347 option.section, option.key, option.default
1348 );
1349 }
1350 println!(
1351 " `spar init --update {}` appends them as comments.",
1352 path.display()
1353 );
1354 }
1355 }
1356
1357 println!(
1358 "{}",
1359 if ok {
1360 "\nready"
1361 } else {
1362 "\nmissing prerequisites"
1363 }
1364 );
1365 Ok(if ok { 0 } else { 1 })
1366}
1367
1368fn first_line(text: &str) -> String {
1369 text.trim().lines().next().unwrap_or("").trim().to_string()
1370}
1371
1372fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1377 println!("\n{}", "=".repeat(60));
1378 for r in results {
1379 println!(
1380 "#{:<5} {:<10} rounds={} {}",
1381 r.issue,
1382 r.status.to_string(),
1383 r.rounds,
1384 r.pr.as_deref().unwrap_or("")
1385 );
1386 for note in &r.notes {
1387 println!(" {}", first_line(note));
1388 }
1389 for url in &r.filed {
1390 println!(" filed {url}");
1391 }
1392 for dispute in &r.disputes {
1393 println!(" disputed: {}", dispute.title);
1394 }
1395 }
1396 println!("{}", "=".repeat(60));
1397
1398 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1399 println!("\nApproved PRs are waiting on you to merge.");
1400 }
1401 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1402 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1403 println!(
1404 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1405 Set followups = \"issues\" to file them."
1406 );
1407 }
1408 if results.iter().all(IssueRun::succeeded) {
1409 0
1410 } else {
1411 1
1412 }
1413}
1414
1415#[cfg(test)]
1416mod tests {
1417 use super::*;
1418 use clap::CommandFactory;
1419
1420 #[test]
1421 fn the_parser_is_internally_consistent() {
1422 Cli::command().debug_assert();
1423 }
1424
1425 #[test]
1426 fn quiet_is_accepted_before_or_after_the_subcommand() {
1427 for argv in [
1428 vec!["spar", "--quiet", "run", "42"],
1429 vec!["spar", "run", "42", "--quiet"],
1430 vec!["spar", "resume", "--quiet"],
1431 vec!["spar", "init", "-q"],
1432 ] {
1433 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1434 }
1435 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1436 }
1437
1438 #[test]
1439 fn several_issue_numbers_are_accepted() {
1440 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1441 match cli.command {
1442 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1443 other => panic!("{other:?}"),
1444 }
1445 }
1446
1447 #[test]
1448 fn issue_numbers_and_flags_can_be_interleaved() {
1449 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1450 match cli.command {
1451 Command::Run {
1452 issues, loop_flags, ..
1453 } => {
1454 assert_eq!(vec![42, 51], issues);
1455 assert!(loop_flags.auto_merge);
1456 }
1457 other => panic!("{other:?}"),
1458 }
1459 }
1460
1461 #[test]
1462 fn every_command_that_reads_a_config_accepts_one() {
1463 for argv in [
1464 vec!["spar", "run", "42"],
1465 vec!["spar", "triage"],
1466 vec!["spar", "resume"],
1467 vec!["spar", "clean"],
1468 vec!["spar", "doctor"],
1469 ] {
1470 let mut full = argv.clone();
1471 full.extend(["--config", "other.toml"]);
1472 let cli = Cli::parse_from(&full);
1473 let config = match cli.command {
1474 Command::Run { common, .. }
1475 | Command::Triage { common, .. }
1476 | Command::Resume { common, .. } => common.config,
1477 Command::Clean { config, .. } | Command::Doctor { config } => config,
1478 other => panic!("{other:?}"),
1479 };
1480 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1481 }
1482 }
1483
1484 #[test]
1485 fn auto_merge_is_off_unless_asked_for() {
1486 let cli = Cli::parse_from(["spar", "run"]);
1487 match cli.command {
1488 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1489 other => panic!("{other:?}"),
1490 }
1491 }
1492
1493 #[test]
1496 fn every_command_that_reads_a_config_takes_instructions() {
1497 for cmd in ["run", "triage", "resume", "review"] {
1498 let argv = vec!["spar", cmd, "7", "--instructions", "Do not wait for CI."];
1499 let parsed = Cli::parse_from(&argv);
1500 let common = match parsed.command {
1501 Command::Run { common, .. }
1502 | Command::Triage { common, .. }
1503 | Command::Resume { common, .. }
1504 | Command::Review { common, .. } => common,
1505 other => panic!("{other:?}"),
1506 };
1507 assert_eq!(
1508 Some("Do not wait for CI."),
1509 common.instructions.as_deref(),
1510 "{cmd}"
1511 );
1512 }
1513 }
1514
1515 #[test]
1516 fn the_two_close_skipped_flags_are_mutually_exclusive() {
1517 assert!(
1518 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1519 );
1520 }
1521
1522 #[test]
1525 fn close_skipped_is_offered_only_where_it_means_something() {
1526 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1527 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1528 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1529 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1530 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1531 }
1532
1533 #[test]
1534 fn the_close_skipped_pair_resolves_to_a_tristate() {
1535 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1536 Command::Run { triage_flags, .. } => {
1537 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1538 (true, _) => Some(true),
1539 (_, true) => Some(false),
1540 _ => None,
1541 }
1542 }
1543 other => panic!("{other:?}"),
1544 };
1545 assert_eq!(None, read(&["spar", "run"]));
1546 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1547 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1548 }
1549
1550 #[test]
1551 fn the_default_limit_is_twenty() {
1552 let cli = Cli::parse_from(["spar", "run"]);
1553 match cli.command {
1554 Command::Run { common, .. } => assert_eq!(20, common.limit),
1555 other => panic!("{other:?}"),
1556 }
1557 }
1558
1559 #[test]
1560 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1561 assert!(matches!(
1562 Cli::parse_from(["spar", "scrub-filter"]).command,
1563 Command::ScrubFilter
1564 ));
1565 let help = Cli::command().render_long_help().to_string();
1566 assert!(
1567 !help.contains("scrub-filter"),
1568 "it is plumbing, not a command"
1569 );
1570 }
1571
1572 #[test]
1573 fn review_takes_pr_numbers_and_a_dry_run() {
1574 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1575 match cli.command {
1576 Command::Review { items, dry_run, .. } => {
1577 assert_eq!(vec![101, 102], items);
1578 assert!(dry_run);
1579 }
1580 other => panic!("{other:?}"),
1581 }
1582 }
1583
1584 #[test]
1585 fn review_posts_unless_told_not_to() {
1586 match Cli::parse_from(["spar", "review", "101"]).command {
1587 Command::Review { dry_run, .. } => assert!(!dry_run),
1588 other => panic!("{other:?}"),
1589 }
1590 }
1591
1592 #[test]
1593 fn review_with_no_numbers_is_allowed() {
1594 match Cli::parse_from(["spar", "review"]).command {
1595 Command::Review { items, .. } => assert!(items.is_empty()),
1596 other => panic!("{other:?}"),
1597 }
1598 }
1599
1600 #[test]
1601 fn review_takes_its_own_round_budget() {
1602 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1603 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1604 other => panic!("{other:?}"),
1605 }
1606 }
1607
1608 #[test]
1609 fn resume_takes_a_next_override() {
1610 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1611 match cli.command {
1612 Command::Resume {
1613 prs, next_actor, ..
1614 } => {
1615 assert_eq!(vec![108], prs);
1616 assert_eq!(Some("codex".to_string()), next_actor);
1617 }
1618 other => panic!("{other:?}"),
1619 }
1620 }
1621}
1622
1623#[cfg(test)]
1624mod absorb_tests {
1625 use super::*;
1626
1627 #[test]
1628 fn absorb_is_off_unless_asked_for() {
1629 match Cli::parse_from(["spar", "run"]).command {
1630 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1631 other => panic!("{other:?}"),
1632 }
1633 }
1634
1635 #[test]
1636 fn absorb_takes_a_wave_count() {
1637 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1638 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1639 other => panic!("{other:?}"),
1640 }
1641 }
1642
1643 #[test]
1644 fn absorb_is_only_offered_where_issues_are_worked() {
1645 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1646 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1647 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1648 }
1649}
1650
1651#[cfg(test)]
1652mod min_number_tests {
1653 use super::*;
1654
1655 fn read(argv: &[&str]) -> Option<i64> {
1656 match Cli::parse_from(argv).command {
1657 Command::Run { common, .. }
1658 | Command::Triage { common, .. }
1659 | Command::Resume { common, .. }
1660 | Command::Review { common, .. } => common.min_number,
1661 other => panic!("{other:?}"),
1662 }
1663 }
1664
1665 #[test]
1666 fn there_is_no_floor_unless_one_is_asked_for() {
1667 assert_eq!(None, read(&["spar", "run"]));
1668 }
1669
1670 #[test]
1671 fn every_command_that_picks_for_itself_accepts_a_floor() {
1672 for cmd in ["run", "triage", "resume", "review"] {
1673 assert_eq!(
1674 Some(480),
1675 read(&["spar", cmd, "--min-number", "480"]),
1676 "{cmd}"
1677 );
1678 }
1679 }
1680}
1681
1682#[cfg(test)]
1683mod settings_block_tests {
1684 use super::*;
1685
1686 fn written(line: &str) -> String {
1689 let after = line.split_once('=').expect("an assignment").1;
1690 let mut quoted = false;
1691 for (i, c) in after.char_indices() {
1692 match c {
1693 '"' => quoted = !quoted,
1694 '#' if !quoted => return after[..i].trim().to_string(),
1695 _ => {}
1696 }
1697 }
1698 after.trim().to_string()
1699 }
1700
1701 fn line_for(text: &str, key: &str) -> String {
1702 text.lines()
1703 .find(|l| {
1704 let bare = l.trim_start().trim_start_matches('#').trim_start();
1705 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1706 })
1707 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
1708 .to_string()
1709 }
1710
1711 #[test]
1718 fn every_value_it_offers_is_the_default_it_actually_has() {
1719 let text = settings_block("claude");
1720 for option in config::known_options() {
1721 if option.section == "loop.effort_schedule" {
1725 continue;
1726 }
1727 let line = line_for(&text, &option.key);
1728 assert_eq!(
1729 option.default,
1730 written(&line),
1731 "the generated config offers `{}`, but the default is {}",
1732 line.trim(),
1733 option.default
1734 );
1735 }
1736 }
1737
1738 #[test]
1742 fn it_offers_every_option_the_parser_knows_about() {
1743 let text = settings_block("claude");
1744 let missing: Vec<String> = config::unmentioned_options(&text)
1745 .into_iter()
1746 .map(|o| format!("[{}] {}", o.section, o.key))
1747 .collect();
1748 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
1749 }
1750
1751 #[test]
1755 fn every_option_it_offers_can_be_uncommented_and_still_load() {
1756 let mut text = String::from(
1757 "[agents.claude]\ncommand = [\"claude\"]\n\n\
1758 [agents.codex]\ncommand = [\"codex\"]\n\n",
1759 );
1760 for line in settings_block("claude").lines() {
1761 text.push_str(uncomment(line).unwrap_or(line));
1762 text.push('\n');
1763 }
1764 let cfg = config::parse(&text).expect("a config of its own suggestions");
1765 assert_eq!("claude", cfg.first_implementor);
1766 }
1767
1768 fn uncomment(line: &str) -> Option<&str> {
1771 let bare = line.trim_start().strip_prefix('#')?.trim_start();
1772 let key = bare.split_once('=')?.0.trim();
1775 let named = !key.is_empty()
1776 && key
1777 .chars()
1778 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1779 named.then_some(bare)
1780 }
1781
1782 #[test]
1783 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
1784 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
1785 }
1786
1787 #[test]
1792 fn a_note_sits_above_the_option_it_describes() {
1793 let text = settings_block("claude");
1794 assert!(
1795 text.lines().all(|l| l.chars().count() <= 80),
1796 "a line runs off the edge:\n{text}"
1797 );
1798 assert!(
1799 text.lines().all(|l| !l.starts_with(' ')),
1800 "a line is indented, so the columns are back:\n{text}"
1801 );
1802
1803 let lines: Vec<&str> = text.lines().collect();
1805 let at = lines
1806 .iter()
1807 .position(|l| l.starts_with("max_rounds"))
1808 .expect("max_rounds");
1809 assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
1810 assert!(
1811 lines[at - 1].contains("lifetime cap"),
1812 "the note above it is the end of its own note: {:?}",
1813 lines[at - 1]
1814 );
1815 }
1816
1817 #[test]
1819 fn every_note_starts_as_a_sentence() {
1820 for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
1821 let first = note.chars().next().expect("a note");
1822 assert!(
1823 first.is_uppercase(),
1824 "{key} reads as a margin scribble rather than a sentence: {note}"
1825 );
1826 }
1827 }
1828}
1829
1830#[cfg(test)]
1831mod agent_block_tests {
1832 use super::*;
1833
1834 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
1835 let mut spec: config::AgentSpec =
1836 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
1837 .try_into()
1838 .expect("builds");
1839 spec.models = models.iter().map(|s| s.to_string()).collect();
1840 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
1841 spec
1842 }
1843
1844 #[test]
1848 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
1849 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
1850 assert!(!block.contains("..."), "{block}");
1851 assert!(!block.contains("effort"), "{block}");
1852 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
1853 }
1854
1855 #[test]
1858 fn only_the_options_that_follow_are_introduced() {
1859 let model_only = agent_block("cursor", &spec(&["auto"], &[]));
1860 assert!(model_only.contains("Omit model to use"), "{model_only}");
1861 assert!(!model_only.contains("Omit effort"), "{model_only}");
1862
1863 let both = agent_block("claude", &spec(&["fable"], &["high"]));
1864 assert!(both.contains("Omit model to use"), "{both}");
1865 assert!(both.contains("Omit effort to use"), "{both}");
1866 }
1867
1868 #[test]
1871 fn a_preset_with_no_hints_still_writes_a_usable_block() {
1872 let block = agent_block("gemini", &spec(&[], &[]));
1873 assert!(!block.contains("..."), "{block}");
1874 assert!(!block.contains("Omit"), "{block}");
1875 assert!(
1876 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
1877 "{block}"
1878 );
1879 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
1881 assert!(block.contains("# timeout = "), "{block}");
1882 }
1883
1884 #[test]
1887 fn the_timeout_offered_is_the_one_the_agent_would_use() {
1888 let mut spec = spec(&["a"], &[]);
1889 spec.timeout = 7200;
1890 assert!(
1891 agent_block("custom", &spec).contains("# timeout = 7200"),
1892 "the generator kept its own copy"
1893 );
1894 }
1895
1896 #[test]
1898 fn alternatives_are_listed_only_when_there_are_any() {
1899 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
1900 let single = agent_block("b", &spec(&["only"], &[]));
1901 assert!(!single.contains("One of:"), "{single}");
1902 }
1903
1904 #[test]
1908 fn the_presets_note_is_said_once() {
1909 let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
1910 spec.options_note = Some("Check the current sets with: mytool --help".into());
1911 let block = agent_block("mytool", &spec);
1912 assert_eq!(
1913 1,
1914 block.matches("Check the current sets").count(),
1915 "{block}"
1916 );
1917 }
1918}