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 log!(" skip #{} (both reviewers: not worth doing)", item.issue);
704 }
705 for item in &plan.contested {
706 log!(" ?? #{} contested, parked for you to decide", item.issue);
707 }
708 Ok(plan)
709}
710
711fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
714 for item in &plan.skipped {
715 let body = review::skip_comment(item, &repo.style);
716 let outcome = if cfg.loop_cfg.close_skipped {
717 repo.close_issue(item.issue, &body)
718 } else {
719 repo.comment_issue(item.issue, &body)
720 };
721 match outcome {
722 Ok(()) if cfg.loop_cfg.close_skipped => log!(" closed #{}", item.issue),
723 Ok(()) => {}
724 Err(e) => logdim!("could not update #{}: {e}", item.issue),
725 }
726 }
727}
728
729fn cmd_scrub_filter() -> Result<i32> {
734 let mut input = String::new();
735 std::io::stdin()
736 .read_to_string(&mut input)
737 .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
738 let out = style::scrub(&input, &crate::repo::style_from_env());
739 let mut stdout = std::io::stdout();
740 stdout
741 .write_all(out.as_bytes())
742 .and_then(|_| stdout.write_all(b"\n"))
743 .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
744 Ok(0)
745}
746
747fn cmd_clean(
748 repo_path: &Path,
749 config_path: Option<&Path>,
750 all: bool,
751 pr_state: bool,
752) -> Result<i32> {
753 let cfg = config::load(config_path)?;
754 let repo = Repo::open(repo_path, &cfg)?;
755 let mut removed = repo.prune_worktrees(all);
756 removed.extend(repo.prune_state());
757 if pr_state {
758 removed.extend(repo.prune_pr_state(None));
759 }
760 if removed.is_empty() {
761 println!("nothing to clean");
762 } else {
763 for item in removed {
764 println!("removed {item}");
765 }
766 }
767 Ok(0)
768}
769
770fn cmd_post(
772 prs: &[i64],
773 repo_path: &Path,
774 config_path: Option<&Path>,
775 file: Option<&Path>,
776 dry_run: bool,
777) -> Result<i32> {
778 let cfg = config::load(config_path)?;
779 let repo = Repo::open(repo_path, &cfg)?;
780
781 if file.is_some() && prs.len() > 1 {
782 bail!("--file posts one review, so give it one pull request number");
783 }
784
785 let mut failed = false;
786 for number in prs {
787 let text = match file {
788 Some(path) => std::fs::read_to_string(path)
789 .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
790 None => match repo.read_pending_comment(*number) {
791 Some(text) => text,
792 None => {
793 logging::error(format!(
794 "no saved review for PR #{number}. `spar review {number} --dry-run` \
795 produces one, or pass --file."
796 ));
797 failed = true;
798 continue;
799 }
800 },
801 };
802 if text.trim().is_empty() {
803 logging::error(format!("the saved review for PR #{number} is empty"));
804 failed = true;
805 continue;
806 }
807 if dry_run {
808 println!("\n{}\n", text.trim());
809 log!("would post the above to PR #{number}");
810 continue;
811 }
812 match repo.comment_pr(*number, &text) {
815 Ok(()) => log!("posted to PR #{number}"),
816 Err(e) => {
817 logging::error(format!("could not post to PR #{number}: {e}"));
818 failed = true;
819 }
820 }
821 }
822 Ok(if failed { 1 } else { 0 })
823}
824
825fn cmd_init_update(out: &Path) -> Result<i32> {
831 let text = std::fs::read_to_string(out)
832 .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
833 config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
836
837 let unset = config::unmentioned_options(&text);
838 if unset.is_empty() {
839 println!("{} already mentions every setting.", out.display());
840 return Ok(0);
841 }
842
843 let mut block = String::new();
844 if !text.ends_with('\n') {
845 block.push('\n');
846 }
847 block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
848 block.push_str("# shown at their defaults. Uncomment one to change it.\n");
849 let mut section = "";
850 for option in &unset {
851 if option.section != section {
852 section = option.section;
853 block.push_str(&format!("# [{section}]\n"));
854 }
855 block.push_str(&format!("# {} = {}\n", option.key, option.default));
856 }
857
858 use std::io::Write;
859 std::fs::OpenOptions::new()
860 .append(true)
861 .open(out)
862 .and_then(|mut f| f.write_all(block.as_bytes()))
863 .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
864
865 println!(
866 "added {} setting(s) to {} as comments",
867 unset.len(),
868 out.display()
869 );
870 Ok(0)
871}
872
873fn cmd_init(out: &Path, force: bool) -> Result<i32> {
874 if out.exists() && !force {
875 logging::error(format!(
876 "{} already exists. `--update` appends any settings it does not mention, \
877 `--force` overwrites it.",
878 out.display()
879 ));
880 return Ok(1);
881 }
882
883 let presets = config::available_presets();
884 if presets.is_empty() {
885 bail!("no presets available, which should be impossible in a released build");
886 }
887
888 let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
889 for name in &presets {
890 let raw = config::load_preset(name)?;
891 let mut spec: config::AgentSpec = match raw
895 .as_table()
896 .cloned()
897 .ok_or_else(|| spar_err!("not a table"))
898 .and_then(|t| {
899 toml::Value::Table(t)
900 .try_into()
901 .map_err(|e| spar_err!("{e}"))
902 }) {
903 Ok(spec) => spec,
904 Err(e) => {
905 println!(" BROKEN {name:10} {}", e.first_line());
906 continue;
907 }
908 };
909 spec.name = name.clone();
910 match Agent::new(spec.clone()).resolve_bin() {
911 Ok(path) => {
912 println!(" found {name:10} {}", path.display());
913 found.push((name.clone(), path.to_path_buf(), spec));
914 }
915 Err(_) => println!(" missing {name}"),
916 }
917 }
918
919 if found.len() < 2 {
920 logging::error(format!(
921 "need two agent CLIs, found {}. Install another, or write {} by hand using the \
922 presets as a reference.",
923 found.len(),
924 out.display()
925 ));
926 return Ok(1);
927 }
928
929 let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
931 if found.len() > 2 {
932 log!(
933 "{} agents available, picking {} and {}. Edit {} to change.",
934 found.len(),
935 chosen[0].0,
936 chosen[1].0,
937 out.display()
938 );
939 }
940
941 let mut text = String::from(
942 "# Generated by `spar init`. Each agent inherits a command template from a\n\
943 # built in preset; anything set here overrides it.\n\
944 #\n\
945 # Commented lines are the other options, each with a working value.\n\
946 # Uncomment one to change it.\n\n",
947 );
948 for (name, _, spec) in &chosen {
949 text.push_str(&agent_block(name, spec));
950 }
951 text.push_str(&settings_block(&chosen[0].0));
952
953 std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
954 println!("\nwrote {}", out.display());
955 println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
956 Ok(0)
957}
958
959fn report_fallback(agent: &Agent) {
965 let Some(backup) = agent.fallback() else {
966 return;
967 };
968 match backup.resolve_bin() {
969 Ok(bin) => println!(
970 " fallback {} ({})",
971 bin.display(),
972 backup.spec.describe()
973 ),
974 Err(_) => println!(
975 " fallback {} not found, so it will not stand in. Set {} to its path.",
976 backup.program(),
977 backup.env_key()
978 ),
979 }
980}
981
982type Setting = (bool, &'static str, &'static str);
993
994const LOOP_OPTIONS: &[Setting] = &[
995 (
996 false,
997 "max_rounds",
998 "review rounds ONE invocation may spend. Resuming grants a fresh budget, so this is not a lifetime cap on a PR.",
999 ),
1000 (
1001 false,
1002 "auto_merge",
1003 "off on purpose: two models agreeing is not the same as being right",
1004 ),
1005 (false, "first_implementor", ""),
1006 (false, "worktrees", "false works in the main checkout"),
1007 (
1008 false,
1009 "close_skipped",
1010 "close an issue both reviewers declined",
1011 ),
1012 (
1013 false,
1014 "followups",
1015 "issues | local | none. local writes .spar/followups.md, not the tracker",
1016 ),
1017 (
1018 true,
1019 "file_non_blocking",
1020 "a suggestion is not a tracker item",
1021 ),
1022 (
1023 true,
1024 "max_followups",
1025 "backstop on what one run can spawn",
1026 ),
1027 (
1028 true,
1029 "keep_worktrees",
1030 "true leaves them behind to inspect",
1031 ),
1032 (
1033 true,
1034 "min_number",
1035 "ignore anything numbered below this when picking for itself. 0 is no floor.",
1036 ),
1037 (
1038 true,
1039 "parallel_triage",
1040 "false asks the agents one at a time",
1041 ),
1042 (
1043 true,
1044 "absorb_new_issues",
1045 "waves of newly filed follow-ups to fold back into this run. Costs more.",
1046 ),
1047 (true, "file_nits", "true files nits as issues too"),
1048 (
1049 true,
1050 "base_branch",
1051 "only a fallback; origin/HEAD wins when it resolves",
1052 ),
1053 (
1054 true,
1055 "branch_prefix",
1056 "e.g. \"spar/\" to namespace the branches spar creates",
1057 ),
1058 (true, "state_store", "local | pr | both"),
1059 (
1060 true,
1061 "max_issue_chars",
1062 "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.",
1063 ),
1064 (
1065 true,
1066 "max_triage_chars",
1067 "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.",
1068 ),
1069];
1070
1071const STYLE_OPTIONS: &[Setting] = &[
1072 (false, "ban_em_dash", ""),
1073 (false, "ban_ai_attribution", ""),
1074 (
1075 false,
1076 "terse",
1077 "hold model prose to a length budget. false removes the valves entirely",
1078 ),
1079 (
1080 true,
1081 "pr_comments",
1082 "outcome | rounds | none. How much of its own working spar narrates into a PR thread. none never comments at all.",
1083 ),
1084 (
1085 true,
1086 "max_title_chars",
1087 "a finding, issue, or PR title. Never ellipsised",
1088 ),
1089 (
1090 true,
1091 "max_summary_chars",
1092 "a one line verdict or refutation",
1093 ),
1094 (
1095 true,
1096 "max_detail_chars",
1097 "a blocking finding, in the PR thread",
1098 ),
1099 (true, "max_body_chars", "a PR body"),
1100 (
1101 true,
1102 "max_issue_body_chars",
1103 "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.",
1104 ),
1105];
1106
1107fn settings_block(first_implementor: &str) -> String {
1112 let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1113 .into_iter()
1114 .map(|option| (option.key, option.default))
1115 .collect();
1116 let value = |key: &str| match key {
1119 "first_implementor" => format!("\"{first_implementor}\""),
1120 other => defaults.get(other).cloned().unwrap_or_default(),
1121 };
1122
1123 let mut out = String::from("[loop]\n");
1124 out.push_str(&option_lines(LOOP_OPTIONS, &value));
1125 out.push_str(concat!(
1126 "\n[loop.effort_schedule]\n",
1127 "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1128 "# these are examples rather than defaults. Left out, each agent uses\n",
1129 "# the effort its own block asked for.\n",
1130 "# round_1 = \"high\" # the deep first review\n",
1131 "# rest = \"low\" # later rounds only see a small delta\n\n",
1132 ));
1133 out.push_str("[style]\n");
1134 out.push_str(&option_lines(STYLE_OPTIONS, &value));
1135 out
1136}
1137
1138fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1142 const WIDTH: usize = 78;
1143
1144 let assignments: Vec<String> = options
1145 .iter()
1146 .map(|(commented, key, _)| {
1147 let lead = if *commented { "# " } else { "" };
1148 format!("{lead}{key} = {}", value(key))
1149 })
1150 .collect();
1151 let column = assignments
1152 .iter()
1153 .map(|a| a.chars().count())
1154 .max()
1155 .unwrap_or(0)
1156 + 2;
1157
1158 let mut out = String::new();
1159 for (assignment, (_, _, note)) in assignments.iter().zip(options) {
1160 if note.is_empty() {
1161 out.push_str(assignment);
1162 out.push('\n');
1163 continue;
1164 }
1165 let mut first = true;
1166 let mut line = String::new();
1167 for word in note.split_whitespace() {
1168 let would_be = column + 2 + line.chars().count() + 1 + word.chars().count();
1169 if !line.is_empty() && would_be > WIDTH {
1170 out.push_str(¬ed(assignment, &line, column, &mut first));
1171 line.clear();
1172 }
1173 if !line.is_empty() {
1174 line.push(' ');
1175 }
1176 line.push_str(word);
1177 }
1178 if !line.is_empty() {
1179 out.push_str(¬ed(assignment, &line, column, &mut first));
1180 }
1181 }
1182 out
1183}
1184
1185fn noted(assignment: &str, note: &str, column: usize, first: &mut bool) -> String {
1188 let lead = if *first {
1189 let pad = column.saturating_sub(assignment.chars().count());
1190 format!("{assignment}{}", " ".repeat(pad))
1191 } else {
1192 " ".repeat(column)
1193 };
1194 *first = false;
1195 format!("{lead}# {note}\n")
1196}
1197
1198type Probe = Box<dyn Fn() -> Result<String>>;
1201
1202fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1209 let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1210
1211 let offered: Vec<(&str, &[String])> = [
1221 ("model ", spec.models.as_slice()),
1222 ("effort", spec.efforts.as_slice()),
1223 ]
1224 .into_iter()
1225 .filter(|(_, choices)| !choices.is_empty())
1226 .collect();
1227
1228 if !offered.is_empty() {
1229 let named: Vec<&str> = offered.iter().map(|(key, _)| key.trim()).collect();
1230 out.push_str(&format!(
1231 "# Omit {} to use the CLI's own default.\n",
1232 named.join(" or ")
1233 ));
1234
1235 let assignments: Vec<String> = offered
1236 .iter()
1237 .map(|(key, choices)| format!("# {key} = \"{}\"", choices[0]))
1238 .collect();
1239 let column = assignments
1242 .iter()
1243 .map(|a| a.chars().count())
1244 .max()
1245 .unwrap_or(0)
1246 + 3;
1247 for (assignment, (_, choices)) in assignments.iter().zip(&offered) {
1248 out.push_str(assignment);
1249 if choices.len() > 1 {
1250 let pad = column.saturating_sub(assignment.chars().count());
1251 out.push_str(&" ".repeat(pad));
1252 out.push_str(&format!("# {}", choices.join(" | ")));
1253 }
1254 out.push('\n');
1255 }
1256 }
1257
1258 if let Some(note) = &spec.options_note {
1259 out.push_str(&wrap_comment(note));
1260 }
1261 let backup = if name == "cursor" { "gemini" } else { "cursor" };
1264 out.push_str("# A stand in for when this CLI refuses, stalls, or runs out of quota.\n");
1265 out.push_str("# It answers in place of this agent, never alongside it.\n");
1266 out.push_str(&format!(
1267 "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1268 ));
1269 out.push('\n');
1270 out
1271}
1272
1273fn wrap_comment(text: &str) -> String {
1275 const WIDTH: usize = 76;
1276 let mut out = String::new();
1277 let mut line = String::from("#");
1278 for word in text.split_whitespace() {
1279 if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1280 out.push_str(&line);
1281 out.push('\n');
1282 line = String::from("#");
1283 }
1284 line.push(' ');
1285 line.push_str(word);
1286 }
1287 if line.len() > 1 {
1288 out.push_str(&line);
1289 out.push('\n');
1290 }
1291 out
1292}
1293
1294fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1295 let mut ok = true;
1296
1297 let probes: Vec<(&str, Probe)> = vec![
1298 (
1299 "git",
1300 Box::new(|| {
1301 proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1302 .map(|s| first_line(&s))
1303 }),
1304 ),
1305 (
1306 "gh",
1307 Box::new(|| {
1308 proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1309 .map(|s| first_line(&s))
1310 }),
1311 ),
1312 (
1313 "gh auth",
1314 Box::new(|| {
1315 let out = proc::exec(
1316 &["gh".into(), "auth".into(), "status".into()],
1317 &ExecOpts::new().check(false).timeout_secs(60),
1318 )?;
1319 let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1320 if out.ok() {
1321 Ok(first_line(&text))
1322 } else {
1323 Err(spar_err!("not authenticated. Run `gh auth login`."))
1324 }
1325 }),
1326 ),
1327 ];
1328
1329 for (label, probe) in probes {
1330 match probe() {
1331 Ok(detail) => println!(" ok {label:12} {detail}"),
1332 Err(e) => {
1333 println!(" FAIL {label:12} {}", e.first_line());
1334 ok = false;
1335 }
1336 }
1337 }
1338
1339 let found = config::find_config(config_path)?;
1340 let Some(path) = found else {
1341 println!("\n no spar.toml found. Run `spar init` to generate one.");
1342 println!(
1343 " presets available: {}",
1344 config::available_presets().join(", ")
1345 );
1346 return Ok(if ok { 0 } else { 1 });
1347 };
1348
1349 println!("\n config: {}", path.display());
1350 let cfg = match config::load(Some(&path)) {
1351 Ok(cfg) => cfg,
1352 Err(e) => {
1353 println!(" FAIL config {e}");
1354 return Ok(1);
1355 }
1356 };
1357
1358 let mut resolved = Vec::new();
1361 for spec in &cfg.agents {
1362 let agent = Agent::new(spec.clone());
1363 match agent.resolve_bin() {
1364 Ok(bin) => {
1365 println!(
1366 " ok {:12} {} ({})",
1367 spec.name,
1368 bin.display(),
1369 spec.describe()
1370 );
1371 report_fallback(&agent);
1372 resolved.push(agent);
1373 }
1374 Err(e) => {
1375 println!(" FAIL {:12} {}", spec.name, e.first_line());
1376 ok = false;
1377 }
1378 }
1379 }
1380
1381 if resolved.len() == cfg.agents.len() {
1382 if let Some(warning) = agent::correlation_warning(&resolved) {
1383 println!("\n WARNING {warning}");
1384 }
1385 }
1386
1387 println!(
1388 "\n settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1389 cfg.loop_cfg.max_rounds,
1390 cfg.loop_cfg.auto_merge,
1391 cfg.loop_cfg.worktrees,
1392 cfg.loop_cfg.followups,
1393 cfg.style.terse
1394 );
1395 if let Ok(text) = std::fs::read_to_string(&path) {
1399 let unset = config::unmentioned_options(&text);
1400 if !unset.is_empty() {
1401 println!(
1402 "\n {} setting(s) this config does not mention, all at their defaults:",
1403 unset.len()
1404 );
1405 for option in &unset {
1406 println!(
1407 " [{}] {} = {}",
1408 option.section, option.key, option.default
1409 );
1410 }
1411 println!(
1412 " `spar init --update {}` appends them as comments.",
1413 path.display()
1414 );
1415 }
1416 }
1417
1418 println!(
1419 "{}",
1420 if ok {
1421 "\nready"
1422 } else {
1423 "\nmissing prerequisites"
1424 }
1425 );
1426 Ok(if ok { 0 } else { 1 })
1427}
1428
1429fn first_line(text: &str) -> String {
1430 text.trim().lines().next().unwrap_or("").trim().to_string()
1431}
1432
1433fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1438 println!("\n{}", "=".repeat(60));
1439 for r in results {
1440 println!(
1441 "#{:<5} {:<10} rounds={} {}",
1442 r.issue,
1443 r.status.to_string(),
1444 r.rounds,
1445 r.pr.as_deref().unwrap_or("")
1446 );
1447 for note in &r.notes {
1448 println!(" {}", first_line(note));
1449 }
1450 for url in &r.filed {
1451 println!(" filed {url}");
1452 }
1453 for dispute in &r.disputes {
1454 println!(" disputed: {}", dispute.title);
1455 }
1456 }
1457 println!("{}", "=".repeat(60));
1458
1459 if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1460 println!("\nApproved PRs are waiting on you to merge.");
1461 }
1462 let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1463 if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1464 println!(
1465 "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1466 Set followups = \"issues\" to file them."
1467 );
1468 }
1469 if results.iter().all(IssueRun::succeeded) {
1470 0
1471 } else {
1472 1
1473 }
1474}
1475
1476#[cfg(test)]
1477mod tests {
1478 use super::*;
1479 use clap::CommandFactory;
1480
1481 #[test]
1482 fn the_parser_is_internally_consistent() {
1483 Cli::command().debug_assert();
1484 }
1485
1486 #[test]
1487 fn quiet_is_accepted_before_or_after_the_subcommand() {
1488 for argv in [
1489 vec!["spar", "--quiet", "run", "42"],
1490 vec!["spar", "run", "42", "--quiet"],
1491 vec!["spar", "resume", "--quiet"],
1492 vec!["spar", "init", "-q"],
1493 ] {
1494 assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1495 }
1496 assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1497 }
1498
1499 #[test]
1500 fn several_issue_numbers_are_accepted() {
1501 let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1502 match cli.command {
1503 Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1504 other => panic!("{other:?}"),
1505 }
1506 }
1507
1508 #[test]
1509 fn issue_numbers_and_flags_can_be_interleaved() {
1510 let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1511 match cli.command {
1512 Command::Run {
1513 issues, loop_flags, ..
1514 } => {
1515 assert_eq!(vec![42, 51], issues);
1516 assert!(loop_flags.auto_merge);
1517 }
1518 other => panic!("{other:?}"),
1519 }
1520 }
1521
1522 #[test]
1523 fn every_command_that_reads_a_config_accepts_one() {
1524 for argv in [
1525 vec!["spar", "run", "42"],
1526 vec!["spar", "triage"],
1527 vec!["spar", "resume"],
1528 vec!["spar", "clean"],
1529 vec!["spar", "doctor"],
1530 ] {
1531 let mut full = argv.clone();
1532 full.extend(["--config", "other.toml"]);
1533 let cli = Cli::parse_from(&full);
1534 let config = match cli.command {
1535 Command::Run { common, .. }
1536 | Command::Triage { common, .. }
1537 | Command::Resume { common, .. } => common.config,
1538 Command::Clean { config, .. } | Command::Doctor { config } => config,
1539 other => panic!("{other:?}"),
1540 };
1541 assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1542 }
1543 }
1544
1545 #[test]
1546 fn auto_merge_is_off_unless_asked_for() {
1547 let cli = Cli::parse_from(["spar", "run"]);
1548 match cli.command {
1549 Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1550 other => panic!("{other:?}"),
1551 }
1552 }
1553
1554 #[test]
1555 fn the_two_close_skipped_flags_are_mutually_exclusive() {
1556 assert!(
1557 Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1558 );
1559 }
1560
1561 #[test]
1564 fn close_skipped_is_offered_only_where_it_means_something() {
1565 assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1566 assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1567 assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1568 assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1569 assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1570 }
1571
1572 #[test]
1573 fn the_close_skipped_pair_resolves_to_a_tristate() {
1574 let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1575 Command::Run { triage_flags, .. } => {
1576 match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1577 (true, _) => Some(true),
1578 (_, true) => Some(false),
1579 _ => None,
1580 }
1581 }
1582 other => panic!("{other:?}"),
1583 };
1584 assert_eq!(None, read(&["spar", "run"]));
1585 assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1586 assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1587 }
1588
1589 #[test]
1590 fn the_default_limit_is_twenty() {
1591 let cli = Cli::parse_from(["spar", "run"]);
1592 match cli.command {
1593 Command::Run { common, .. } => assert_eq!(20, common.limit),
1594 other => panic!("{other:?}"),
1595 }
1596 }
1597
1598 #[test]
1599 fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1600 assert!(matches!(
1601 Cli::parse_from(["spar", "scrub-filter"]).command,
1602 Command::ScrubFilter
1603 ));
1604 let help = Cli::command().render_long_help().to_string();
1605 assert!(
1606 !help.contains("scrub-filter"),
1607 "it is plumbing, not a command"
1608 );
1609 }
1610
1611 #[test]
1612 fn review_takes_pr_numbers_and_a_dry_run() {
1613 let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1614 match cli.command {
1615 Command::Review { items, dry_run, .. } => {
1616 assert_eq!(vec![101, 102], items);
1617 assert!(dry_run);
1618 }
1619 other => panic!("{other:?}"),
1620 }
1621 }
1622
1623 #[test]
1624 fn review_posts_unless_told_not_to() {
1625 match Cli::parse_from(["spar", "review", "101"]).command {
1626 Command::Review { dry_run, .. } => assert!(!dry_run),
1627 other => panic!("{other:?}"),
1628 }
1629 }
1630
1631 #[test]
1632 fn review_with_no_numbers_is_allowed() {
1633 match Cli::parse_from(["spar", "review"]).command {
1634 Command::Review { items, .. } => assert!(items.is_empty()),
1635 other => panic!("{other:?}"),
1636 }
1637 }
1638
1639 #[test]
1640 fn review_takes_its_own_round_budget() {
1641 match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1642 Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1643 other => panic!("{other:?}"),
1644 }
1645 }
1646
1647 #[test]
1648 fn resume_takes_a_next_override() {
1649 let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1650 match cli.command {
1651 Command::Resume {
1652 prs, next_actor, ..
1653 } => {
1654 assert_eq!(vec![108], prs);
1655 assert_eq!(Some("codex".to_string()), next_actor);
1656 }
1657 other => panic!("{other:?}"),
1658 }
1659 }
1660}
1661
1662#[cfg(test)]
1663mod absorb_tests {
1664 use super::*;
1665
1666 #[test]
1667 fn absorb_is_off_unless_asked_for() {
1668 match Cli::parse_from(["spar", "run"]).command {
1669 Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1670 other => panic!("{other:?}"),
1671 }
1672 }
1673
1674 #[test]
1675 fn absorb_takes_a_wave_count() {
1676 match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1677 Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1678 other => panic!("{other:?}"),
1679 }
1680 }
1681
1682 #[test]
1683 fn absorb_is_only_offered_where_issues_are_worked() {
1684 assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1685 assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1686 assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1687 }
1688}
1689
1690#[cfg(test)]
1691mod min_number_tests {
1692 use super::*;
1693
1694 fn read(argv: &[&str]) -> Option<i64> {
1695 match Cli::parse_from(argv).command {
1696 Command::Run { common, .. }
1697 | Command::Triage { common, .. }
1698 | Command::Resume { common, .. }
1699 | Command::Review { common, .. } => common.min_number,
1700 other => panic!("{other:?}"),
1701 }
1702 }
1703
1704 #[test]
1705 fn there_is_no_floor_unless_one_is_asked_for() {
1706 assert_eq!(None, read(&["spar", "run"]));
1707 }
1708
1709 #[test]
1710 fn every_command_that_picks_for_itself_accepts_a_floor() {
1711 for cmd in ["run", "triage", "resume", "review"] {
1712 assert_eq!(
1713 Some(480),
1714 read(&["spar", cmd, "--min-number", "480"]),
1715 "{cmd}"
1716 );
1717 }
1718 }
1719}
1720
1721#[cfg(test)]
1722mod settings_block_tests {
1723 use super::*;
1724
1725 fn written(line: &str) -> String {
1728 let after = line.split_once('=').expect("an assignment").1;
1729 let mut quoted = false;
1730 for (i, c) in after.char_indices() {
1731 match c {
1732 '"' => quoted = !quoted,
1733 '#' if !quoted => return after[..i].trim().to_string(),
1734 _ => {}
1735 }
1736 }
1737 after.trim().to_string()
1738 }
1739
1740 fn line_for(text: &str, key: &str) -> String {
1741 text.lines()
1742 .find(|l| {
1743 let bare = l.trim_start().trim_start_matches('#').trim_start();
1744 bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1745 })
1746 .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
1747 .to_string()
1748 }
1749
1750 #[test]
1757 fn every_value_it_offers_is_the_default_it_actually_has() {
1758 let text = settings_block("claude");
1759 for option in config::known_options() {
1760 if option.section == "loop.effort_schedule" {
1764 continue;
1765 }
1766 let line = line_for(&text, &option.key);
1767 assert_eq!(
1768 option.default,
1769 written(&line),
1770 "the generated config offers `{}`, but the default is {}",
1771 line.trim(),
1772 option.default
1773 );
1774 }
1775 }
1776
1777 #[test]
1781 fn it_offers_every_option_the_parser_knows_about() {
1782 let text = settings_block("claude");
1783 let missing: Vec<String> = config::unmentioned_options(&text)
1784 .into_iter()
1785 .map(|o| format!("[{}] {}", o.section, o.key))
1786 .collect();
1787 assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
1788 }
1789
1790 #[test]
1794 fn every_option_it_offers_can_be_uncommented_and_still_load() {
1795 let mut text = String::from(
1796 "[agents.claude]\ncommand = [\"claude\"]\n\n\
1797 [agents.codex]\ncommand = [\"codex\"]\n\n",
1798 );
1799 for line in settings_block("claude").lines() {
1800 text.push_str(uncomment(line).unwrap_or(line));
1801 text.push('\n');
1802 }
1803 let cfg = config::parse(&text).expect("a config of its own suggestions");
1804 assert_eq!("claude", cfg.first_implementor);
1805 }
1806
1807 fn uncomment(line: &str) -> Option<&str> {
1810 let bare = line.trim_start().strip_prefix('#')?.trim_start();
1811 let key = bare.split_once('=')?.0.trim();
1814 let named = !key.is_empty()
1815 && key
1816 .chars()
1817 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1818 named.then_some(bare)
1819 }
1820
1821 #[test]
1822 fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
1823 assert!(settings_block("codex").contains("first_implementor = \"codex\""));
1824 }
1825
1826 #[test]
1829 fn a_wrapped_note_stays_in_its_column() {
1830 let text = settings_block("claude");
1831 let column = text
1832 .lines()
1833 .find(|l| l.starts_with("max_rounds"))
1834 .and_then(|l| l.find('#'))
1835 .expect("a note on max_rounds");
1836 let continuation = text
1837 .lines()
1838 .find(|l| l.starts_with(" ") && l.trim_start().starts_with('#'))
1839 .expect("a wrapped note");
1840 assert_eq!(Some(column), continuation.find('#'));
1841 assert!(text.lines().all(|l| l.chars().count() <= 80), "{text}");
1842 }
1843}
1844
1845#[cfg(test)]
1846mod agent_block_tests {
1847 use super::*;
1848
1849 fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
1850 let mut spec: config::AgentSpec =
1851 toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
1852 .try_into()
1853 .expect("builds");
1854 spec.models = models.iter().map(|s| s.to_string()).collect();
1855 spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
1856 spec
1857 }
1858
1859 #[test]
1863 fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
1864 let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
1865 assert!(!block.contains("..."), "{block}");
1866 assert!(!block.contains("effort"), "{block}");
1867 assert!(block.contains("# model = \"composer-2.5\""), "{block}");
1868 }
1869
1870 #[test]
1874 fn the_header_names_only_the_options_that_follow() {
1875 assert!(agent_block("cursor", &spec(&["auto"], &[])).contains("Omit model to use"));
1876 assert!(
1877 agent_block("claude", &spec(&["fable"], &["high"])).contains("Omit model or effort")
1878 );
1879 }
1880
1881 #[test]
1884 fn a_preset_with_no_hints_still_writes_a_usable_block() {
1885 let block = agent_block("gemini", &spec(&[], &[]));
1886 assert!(!block.contains("..."), "{block}");
1887 assert!(!block.contains("Omit"), "{block}");
1888 assert!(
1889 block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
1890 "{block}"
1891 );
1892 assert!(block.contains("[agents.gemini.fallback]"), "{block}");
1894 }
1895
1896 #[test]
1899 fn alternatives_are_listed_only_when_there_are_any() {
1900 assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("# one | two"));
1901 let single = agent_block("b", &spec(&["only"], &[]));
1902 assert!(!single.contains('|'), "{single}");
1903 }
1904}