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