Skip to main content

spar/
cli.rs

1//! The command line.
2
3use std::collections::{BTreeSet, VecDeque};
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use clap::{Args, Parser, Subcommand};
8
9use crate::agent::{self, Agent};
10use crate::checkin;
11use crate::config::{self, Config};
12use crate::error::Result;
13use crate::followups;
14use crate::model::{Issue, IssueRun, ItemKind, Plan, Status};
15use crate::proc::{self, ExecOpts};
16use crate::repo::{Repo, WriteSummary};
17use crate::review;
18use crate::review_only;
19use crate::split;
20use crate::style;
21use crate::tracker;
22use crate::triage;
23use crate::{bail, log, logdim, logging, logwarn, spar_err};
24
25pub const VERSION: &str = env!("CARGO_PKG_VERSION");
26
27#[derive(Parser, Debug)]
28#[command(
29    name = "spar",
30    version = VERSION,
31    about = "Two coding agents alternate implementing and reviewing GitHub issues.",
32    long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
33                  pull request converges. Neither agent reviews its own most recent edit.\n\n\
34                  Arguments are issue numbers for `run` and `triage`, and pull request numbers \
35                  for `resume`, `review`, and `checkin`. `split` takes either. Omit them and \
36                  spar takes everything open, up to --limit. `split` applies that limit once to \
37                  issues and once to pull requests. `followup` takes none: it works the queue in \
38                  .spar/followups.md, and an entry there has no number to name.",
39    max_term_width = 96
40)]
41pub struct Cli {
42    /// Suppress progress logging. Warnings, errors, and the final summary still print.
43    #[arg(short, long, global = true)]
44    pub quiet: bool,
45
46    #[command(subcommand)]
47    pub command: Command,
48}
49
50#[derive(Subcommand, Debug)]
51pub enum Command {
52    /// Triage the issues, then work them in dependency order.
53    Run {
54        /// Issue numbers. Omit to take every open issue, up to --limit.
55        issues: Vec<i64>,
56        #[command(flatten)]
57        common: Common,
58        #[command(flatten)]
59        loop_flags: LoopFlags,
60        #[command(flatten)]
61        triage_flags: TriageFlags,
62        /// Where to write the triage plan.
63        #[arg(long, default_value = "plan.json")]
64        plan_out: PathBuf,
65        /// Work in the main checkout instead of an isolated worktree per issue.
66        #[arg(long)]
67        no_worktrees: bool,
68    },
69
70    /// Work the follow-ups recorded in .spar/followups.md.
71    ///
72    /// One agent reads every entry against the current checkout and rules on
73    /// it: still there, already fixed, not worth it, or a duplicate. What
74    /// survives is filed as an issue and worked like any other, which means
75    /// both agents still triage it before anything is implemented. An entry
76    /// that was filed or dropped leaves the queue and is kept in
77    /// .spar/followups.done.md.
78    ///
79    /// Takes no numbers: an entry has no number a person could type. --limit
80    /// caps how many are taken, and --min-number does nothing here.
81    Followup {
82        #[command(flatten)]
83        common: Common,
84        #[command(flatten)]
85        loop_flags: LoopFlags,
86        #[command(flatten)]
87        triage_flags: TriageFlags,
88        /// Read this instead of .spar/followups.md.
89        #[arg(long, value_name = "PATH")]
90        file: Option<PathBuf>,
91        /// Print the verdicts and stop. Nothing is filed and no file is touched.
92        #[arg(long)]
93        screen_only: bool,
94        /// File the issues and stop, leaving them for a later `spar run`.
95        #[arg(long, conflicts_with = "screen_only")]
96        file_only: bool,
97        /// Where to write the triage plan.
98        #[arg(long, default_value = "plan.json")]
99        plan_out: PathBuf,
100        /// Work in the main checkout instead of an isolated worktree per issue.
101        #[arg(long)]
102        no_worktrees: bool,
103    },
104
105    /// Triage only. Writes the plan and touches nothing else.
106    Triage {
107        /// Issue numbers. Omit to take every open issue, up to --limit.
108        issues: Vec<i64>,
109        #[command(flatten)]
110        common: Common,
111        #[arg(long, default_value = "plan.json")]
112        plan_out: PathBuf,
113    },
114
115    /// Continue the review loop on existing PRs, including ones spar did not create.
116    Resume {
117        /// Pull request numbers. Omit to take every open PR, up to --limit.
118        prs: Vec<i64>,
119        #[command(flatten)]
120        common: Common,
121        #[command(flatten)]
122        loop_flags: LoopFlags,
123        /// Which agent reviews next, overriding saved custody after the PR head changed.
124        #[arg(long = "next", value_name = "AGENT")]
125        next_actor: Option<String>,
126    },
127
128    /// Answer the comments on a pull request, and act on the ones worth acting on.
129    ///
130    /// Reads every comment somebody else left that has not been answered. Both
131    /// agents judge each one. A change they both agree is right and belongs
132    /// here is made, pushed, answered in its own thread, and the thread marked
133    /// resolved. One they both judge wrong gets the reason and the thread is
134    /// left open for you. One they disagree about is parked.
135    Checkin {
136        /// Pull request numbers. An issue number resolves to its open PR.
137        /// Omit to take every open PR, up to --limit.
138        items: Vec<i64>,
139        #[command(flatten)]
140        common: Common,
141        /// Print every reply and every change instead of posting or pushing.
142        #[arg(long)]
143        dry_run: bool,
144        /// Answer in words only. Nothing is committed, pushed, or resolved.
145        #[arg(long)]
146        reply_only: bool,
147        /// Act on a comment from anyone, not only from somebody who can write
148        /// to this repository.
149        #[arg(long)]
150        any_author: bool,
151        /// Answer comments spar already answered, ignoring what it recorded.
152        #[arg(long)]
153        again: bool,
154        /// Leave worktrees in place afterwards, for inspection.
155        #[arg(long)]
156        keep_worktrees: bool,
157    },
158
159    /// Review pull requests without changing them, including from a fork.
160    ///
161    /// Both agents review independently, then rule on each other's findings,
162    /// then answer the objections. Nothing is committed, pushed, or merged.
163    Review {
164        /// Pull request numbers. An issue number resolves to its open PR.
165        /// Omit to take every open PR, up to --limit.
166        items: Vec<i64>,
167        #[command(flatten)]
168        common: Common,
169        /// Print the review instead of posting it.
170        #[arg(long)]
171        dry_run: bool,
172        /// Adjudication passes. 1 is two independent reviews with no
173        /// cross-checking, 2 adds it, 3 adds a rebuttal on what they dispute.
174        #[arg(long)]
175        max_rounds: Option<u32>,
176    },
177
178    /// Break an issue or a pull request into smaller ones.
179    ///
180    /// One agent proposes the parts with the code open, the other rules on the
181    /// proposal: accept, reject, or accept with named parts struck.
182    /// Disagreement resolves toward not splitting.
183    ///
184    /// An issue's parts are filed as issues and the parent is rewritten into a
185    /// checklist that points at them. A pull request's parts each get their own
186    /// branch and their own pull request, and the original is left open and
187    /// otherwise untouched: split branches use create-only pushes, and the
188    /// parent is never closed or rebased. A pull request from a fork is proposed
189    /// in a comment rather than split.
190    ///
191    /// It decomposes and stops. Nothing is triaged, implemented, or merged. Run
192    /// the reported child numbers next, or enable `decompose_trackers` and run
193    /// the issue parent.
194    Split {
195        /// Issue or pull request numbers. Omit to go through every open issue
196        /// and every open pull request, and split what is worth splitting.
197        items: Vec<i64>,
198        #[command(flatten)]
199        common: Common,
200        /// Print the proposal and write nothing.
201        #[arg(long)]
202        dry_run: bool,
203        /// Start a separate split even when one is recorded or retained. Does
204        /// not resume retained branches.
205        #[arg(long)]
206        again: bool,
207    },
208
209    /// Post a review a dry run produced, without running the agents again.
210    ///
211    /// `spar review <pr> --dry-run` saves what it produced. Read it, edit the
212    /// file if you like, then post exactly that.
213    Post {
214        /// Pull request numbers whose saved review should be posted.
215        #[arg(required = true)]
216        prs: Vec<i64>,
217        #[arg(long, default_value = ".")]
218        repo: PathBuf,
219        #[arg(long)]
220        config: Option<PathBuf>,
221        /// Post this file instead of the saved review.
222        #[arg(long, value_name = "PATH")]
223        file: Option<PathBuf>,
224        /// Print what would be posted and stop.
225        #[arg(long)]
226        dry_run: bool,
227    },
228
229    /// Detect installed agent CLIs and write a spar.toml.
230    ///
231    /// On an existing config, `--update` appends any settings it does not
232    /// mention, which is how to pick up options added by a newer release.
233    Init {
234        #[arg(long, default_value = "spar.toml")]
235        out: PathBuf,
236        /// Overwrite an existing config.
237        #[arg(long)]
238        force: bool,
239        /// Append settings the existing config does not mention, as comments.
240        /// Nothing already in the file is changed.
241        #[arg(long, conflicts_with = "force")]
242        update: bool,
243    },
244
245    /// Remove worktrees, branches, and state whose PR is merged or closed.
246    Clean {
247        #[arg(long, default_value = ".")]
248        repo: PathBuf,
249        #[arg(long)]
250        config: Option<PathBuf>,
251        /// Remove every local worktree and local branch spar recorded, even for
252        /// open PRs.
253        #[arg(long)]
254        all: bool,
255        /// Also delete state comments left on finished PRs.
256        #[arg(long)]
257        pr_state: bool,
258    },
259
260    /// Check prerequisites and resolve each configured agent.
261    Doctor {
262        #[arg(long)]
263        config: Option<PathBuf>,
264    },
265
266    /// Read a commit message on stdin and write the scrubbed version to stdout.
267    ///
268    /// Used by `git filter-branch`, not by people.
269    #[command(hide = true)]
270    ScrubFilter,
271}
272
273#[derive(Args, Debug, Clone)]
274pub struct Common {
275    /// Path to the git repository.
276    #[arg(long, default_value = ".")]
277    pub repo: PathBuf,
278    /// Path to spar.toml.
279    #[arg(long)]
280    pub config: Option<PathBuf>,
281    /// Base branch. Defaults to whatever origin/HEAD points at.
282    #[arg(long)]
283    pub base: Option<String>,
284    /// Which agent implements first. A key from the `[agents]` table.
285    #[arg(long)]
286    pub first: Option<String>,
287    /// Cap on open items of each kind when none are named. Bare `split` may take
288    /// this many issues and this many pull requests.
289    #[arg(long, default_value_t = 20)]
290    pub limit: usize,
291    /// Ignore issues and pull requests numbered below this when picking for
292    /// itself. A number you name explicitly is always honoured.
293    #[arg(long, value_name = "N")]
294    pub min_number: Option<i64>,
295    /// Extra instructions for both agents, for this run only. Added to any
296    /// already in the config rather than replacing them.
297    #[arg(long, value_name = "TEXT")]
298    pub instructions: Option<String>,
299}
300
301#[derive(Args, Debug, Clone)]
302pub struct LoopFlags {
303    /// Review rounds this run may spend asking for changes. A closing pass,
304    /// when needed, is not one of them. Resuming grants a fresh budget.
305    #[arg(long)]
306    pub max_rounds: Option<u32>,
307    /// Merge when no blocking findings remain. Off by default, deliberately.
308    #[arg(long)]
309    pub auto_merge: bool,
310    /// Leave worktrees in place after a run, for inspection.
311    #[arg(long)]
312    pub keep_worktrees: bool,
313    /// Waves of newly filed follow-ups to fold back into this run instead of
314    /// leaving them for the next one. Each wave is triaged like any issue.
315    #[arg(long, value_name = "N")]
316    pub absorb: Option<u32>,
317}
318
319/// Only `run` triages, so only `run` can decline an issue. Offering these on
320/// `resume` would accept a flag that does nothing.
321#[derive(Args, Debug, Clone)]
322pub struct TriageFlags {
323    /// Close an issue both agents declined, after posting the reasoning.
324    #[arg(long, conflicts_with = "no_close_skipped")]
325    pub close_skipped: bool,
326    /// Comment on a declined issue but leave it open.
327    #[arg(long)]
328    pub no_close_skipped: bool,
329}
330
331// ---------------------------------------------------------------------------
332// Entry
333// ---------------------------------------------------------------------------
334
335pub fn main() -> i32 {
336    let cli = Cli::parse();
337    logging::init_color();
338    logging::set_quiet(cli.quiet);
339
340    match dispatch(cli) {
341        Ok(code) => code,
342        Err(e) => {
343            logging::error(e.to_string());
344            2
345        }
346    }
347}
348
349fn dispatch(cli: Cli) -> Result<i32> {
350    match cli.command {
351        Command::ScrubFilter => cmd_scrub_filter(),
352        Command::Doctor { config } => cmd_doctor(config.as_deref()),
353        Command::Review {
354            items,
355            common,
356            dry_run,
357            max_rounds,
358        } => {
359            let overrides = Overrides {
360                max_rounds,
361                ..Overrides::default()
362            };
363            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
364            let numbers = if items.is_empty() {
365                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
366                if found.is_empty() {
367                    log!("no open PRs");
368                    return Ok(0);
369                }
370                log!("no PRs given, reviewing {} open", found.len());
371                found
372            } else {
373                items
374            };
375            let sorted = classify(&repo, &numbers)?;
376            let mut targets = sorted.prs;
377            for number in sorted.issues {
378                match repo.open_pr_for_issue(number) {
379                    Some(pr) => {
380                        log!("#{number} is an issue; reviewing its open PR {}", pr.url);
381                        targets.push(pr.number);
382                    }
383                    None => logwarn!("#{number} is an issue with no open pull request to review"),
384                }
385            }
386            let mut results = Vec::new();
387            for number in targets {
388                results.push(review_only::review_pr(
389                    &agents, &cfg, &repo, number, dry_run,
390                ));
391            }
392            if results.is_empty() {
393                return Ok(0);
394            }
395            Ok(report(&results, &cfg, &repo))
396        }
397
398        Command::Checkin {
399            items,
400            common,
401            dry_run,
402            reply_only,
403            any_author,
404            again,
405            keep_worktrees,
406        } => {
407            let overrides = Overrides {
408                keep_worktrees: keep_worktrees.then_some(true),
409                ..Overrides::default()
410            };
411            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
412            let mode = checkin::Mode {
413                dry_run,
414                reply_only,
415                trust: if any_author {
416                    crate::config::Trust::Anyone
417                } else {
418                    cfg.loop_cfg.checkin_trust
419                },
420                again,
421                resolve: cfg.loop_cfg.checkin_resolve,
422                posts: checkin::posts(&cfg),
423            };
424            let numbers = if items.is_empty() {
425                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
426                if found.is_empty() {
427                    log!("no open PRs");
428                    return Ok(0);
429                }
430                log!(
431                    "no PRs given, checking in on {} open: {}",
432                    found.len(),
433                    found
434                        .iter()
435                        .map(|n| format!("#{n}"))
436                        .collect::<Vec<_>>()
437                        .join(", ")
438                );
439                found
440            } else {
441                items
442            };
443            let sorted = classify(&repo, &numbers)?;
444            let mut results = Vec::new();
445            for number in sorted.prs {
446                results.push(checkin::checkin_pr(&agents, &cfg, &repo, number, &mode));
447            }
448            // A change request left on the issue is exactly what this exists to
449            // catch, and when the issue has work open there is a branch to act
450            // on, so route to it rather than refusing.
451            for number in sorted.issues {
452                match repo.open_pr_for_issue(number) {
453                    Some(pr) => {
454                        log!(
455                            "#{number} is an issue; checking in on its open PR {}",
456                            pr.url
457                        );
458                        results.push(checkin::checkin_pr(&agents, &cfg, &repo, pr.number, &mode));
459                    }
460                    None => {
461                        results.push(checkin::checkin_issue(&agents, &cfg, &repo, number, &mode))
462                    }
463                }
464            }
465            if results.is_empty() {
466                return Ok(0);
467            }
468            Ok(report(&results, &cfg, &repo))
469        }
470
471        Command::Split {
472            items,
473            common,
474            dry_run,
475            again,
476        } => {
477            let (cfg, repo, agents) = prepare(&common, None)?;
478            let mode = split::Mode { dry_run, again };
479            let picked = if items.is_empty() {
480                pick_for_split(&agents, &cfg, &repo, common.limit, &mode)?
481            } else {
482                let sorted = classify(&repo, &items)?;
483                let mut out: Vec<(i64, ItemKind)> = sorted
484                    .issues
485                    .iter()
486                    .map(|n| (*n, ItemKind::Issue))
487                    .collect();
488                out.extend(sorted.prs.iter().map(|n| (*n, ItemKind::Pr)));
489                out
490            };
491
492            let mut results = Vec::new();
493            let mut seen: BTreeSet<i64> = BTreeSet::new();
494            for (number, kind) in picked {
495                // An issue whose work is half done produces children describing
496                // work that already exists on a branch, so it routes to the
497                // branch instead, the way `checkin` and `review` already do.
498                let target = match kind {
499                    ItemKind::Pr => Some(number),
500                    ItemKind::Issue => repo.open_pr_for_issue(number).map(|pr| {
501                        log!("#{number} is an issue; splitting its open PR {}", pr.url);
502                        pr.number
503                    }),
504                };
505                // An issue and the pull request it routes to can both be named,
506                // and the queue can hold both. Splitting one pull request twice
507                // makes two sets of branches and pull requests out of it, which
508                // `--again` would not stop.
509                if !seen.insert(target.unwrap_or(number)) {
510                    logdim!("#{number} was already covered by this run, skipping it");
511                    continue;
512                }
513                results.push(match target {
514                    Some(pr) => split::split_pr(&agents, &cfg, &repo, pr, &mode),
515                    None => split::split_issue(&agents, &cfg, &repo, number, &mode),
516                });
517            }
518            if results.is_empty() {
519                return Ok(0);
520            }
521            Ok(report(&results, &cfg, &repo))
522        }
523
524        Command::Post {
525            prs,
526            repo: repo_path,
527            config,
528            file,
529            dry_run,
530        } => cmd_post(
531            &prs,
532            &repo_path,
533            config.as_deref(),
534            file.as_deref(),
535            dry_run,
536        ),
537
538        Command::Init { out, force, update } => {
539            if update {
540                cmd_init_update(&out)
541            } else {
542                cmd_init(&out, force)
543            }
544        }
545        Command::Clean {
546            repo,
547            config,
548            all,
549            pr_state,
550        } => cmd_clean(&repo, config.as_deref(), all, pr_state),
551        Command::Triage {
552            issues,
553            common,
554            plan_out,
555        } => {
556            let (cfg, repo, agents) = prepare(&common, None)?;
557            let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
558            if numbers.is_empty() {
559                return Ok(0);
560            }
561            let sorted = classify(&repo, &numbers)?;
562            for number in &sorted.prs {
563                log!("#{number} is a pull request, nothing to triage");
564            }
565            if sorted.issues.is_empty() {
566                log!("no issues to triage");
567                return Ok(0);
568            }
569            let issues = repo.fetch_issues(&sorted.issues)?;
570            // Deliberately no act_on_plan here. `triage` is the command you
571            // reach for to look before leaping, and a preview that comments on
572            // and closes issues is a trap.
573            let plan = make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
574            // A preview that files issues and rewrites somebody's issue body is
575            // that same trap, so this prints the decomposition and writes none
576            // of it. It is where the first few real trackers should be checked.
577            if cfg.loop_cfg.decompose_trackers {
578                for item in plan.skipped.iter().filter(|i| i.tracker) {
579                    tracker::preview(&cfg, &repo, item.issue);
580                }
581            }
582            Ok(0)
583        }
584        Command::Run {
585            issues,
586            common,
587            loop_flags,
588            triage_flags,
589            plan_out,
590            no_worktrees,
591        } => {
592            let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
593            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
594            let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
595            if numbers.is_empty() {
596                return Ok(0);
597            }
598            let sorted = classify(&repo, &numbers)?;
599            let mut results = Vec::new();
600            work_issues(
601                &agents,
602                &cfg,
603                &repo,
604                sorted.issues.clone(),
605                &plan_out,
606                &mut results,
607            )?;
608
609            for number in sorted.prs {
610                results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
611            }
612
613            if results.is_empty() {
614                log!("nothing scheduled");
615                return Ok(report_writes(&repo));
616            }
617            Ok(report(&results, &cfg, &repo))
618        }
619        Command::Followup {
620            common,
621            loop_flags,
622            triage_flags,
623            file,
624            screen_only,
625            file_only,
626            plan_out,
627            no_worktrees,
628        } => {
629            let overrides = Overrides::for_working(&loop_flags, &triage_flags, no_worktrees);
630            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
631            let path = file.unwrap_or_else(|| repo.followups_path());
632            let mode = match (screen_only, file_only) {
633                (true, _) => followups::Mode::ScreenOnly,
634                (_, true) => followups::Mode::FileOnly,
635                _ => followups::Mode::Work,
636            };
637            let outcome = followups::run(&agents, &cfg, &repo, &path, common.limit, mode)?;
638
639            let wave = followups::wave(&outcome);
640            if mode != followups::Mode::Work || wave.is_empty() {
641                return Ok(report_writes(&repo));
642            }
643            let mut results = Vec::new();
644            work_issues(&agents, &cfg, &repo, wave, &plan_out, &mut results)?;
645            if results.is_empty() {
646                log!("nothing scheduled");
647                return Ok(report_writes(&repo));
648            }
649            Ok(report(&results, &cfg, &repo))
650        }
651
652        Command::Resume {
653            prs,
654            common,
655            loop_flags,
656            next_actor,
657        } => {
658            let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
659            if let Some(name) = &next_actor {
660                if !cfg.has_agent(name) {
661                    bail!("--next must be one of: {}", cfg.agent_names().join(", "));
662                }
663            }
664            let numbers = if prs.is_empty() {
665                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
666                if found.is_empty() {
667                    log!("no open PRs");
668                    return Ok(0);
669                }
670                log!(
671                    "no PRs given, taking {} open: {}",
672                    found.len(),
673                    found
674                        .iter()
675                        .map(|n| format!("#{n}"))
676                        .collect::<Vec<_>>()
677                        .join(", ")
678                );
679                found
680            } else {
681                prs
682            };
683            let sorted = classify(&repo, &numbers)?;
684            let mut results = Vec::new();
685            for number in sorted.prs {
686                results.push(review::resume_pr(
687                    &agents,
688                    &cfg,
689                    &repo,
690                    number,
691                    next_actor.as_deref(),
692                ));
693            }
694            // An issue number handed to `resume` is not a mistake worth
695            // refusing over. If work is already open for it, continue that.
696            for number in sorted.issues {
697                match repo.open_pr_for_issue(number) {
698                    Some(pr) => {
699                        log!("#{number} is an issue; continuing its open PR {}", pr.url);
700                        results.push(review::resume_pr(
701                            &agents,
702                            &cfg,
703                            &repo,
704                            pr.number,
705                            next_actor.as_deref(),
706                        ));
707                    }
708                    None => logwarn!(
709                        "#{number} is an issue with no open pull request. Use `spar run {number}` \
710                         to implement it."
711                    ),
712                }
713            }
714            if results.is_empty() {
715                return Ok(0);
716            }
717            Ok(report(&results, &cfg, &repo))
718        }
719    }
720}
721
722// ---------------------------------------------------------------------------
723// Shared setup
724// ---------------------------------------------------------------------------
725
726#[derive(Debug, Default, Clone)]
727struct Overrides {
728    max_rounds: Option<u32>,
729    auto_merge: Option<bool>,
730    keep_worktrees: Option<bool>,
731    worktrees: Option<bool>,
732    close_skipped: Option<bool>,
733    absorb: Option<u32>,
734}
735
736impl From<&LoopFlags> for Overrides {
737    fn from(flags: &LoopFlags) -> Self {
738        Self {
739            max_rounds: flags.max_rounds,
740            auto_merge: flags.auto_merge.then_some(true),
741            keep_worktrees: flags.keep_worktrees.then_some(true),
742            worktrees: None,
743            close_skipped: None,
744            absorb: flags.absorb,
745        }
746    }
747}
748
749impl Overrides {
750    /// What `run` and `followup` share past the loop flags: both triage, so
751    /// both can decline, and both work issues in a worktree apiece.
752    fn for_working(loop_flags: &LoopFlags, triage: &TriageFlags, no_worktrees: bool) -> Self {
753        let mut over = Overrides::from(loop_flags);
754        over.worktrees = if no_worktrees { Some(false) } else { None };
755        over.close_skipped = match (triage.close_skipped, triage.no_close_skipped) {
756            (true, _) => Some(true),
757            (_, true) => Some(false),
758            _ => None,
759        };
760        over
761    }
762}
763
764/// Triage a set of issues, work them in dependency order, and fold each wave of
765/// newly filed follow-ups back in as the absorb budget allows.
766///
767/// Shared by `run` and `followup`, which differ only in where the first wave
768/// comes from: `run` takes it from the tracker, `followup` from the issues it
769/// just filed out of the local queue. Everything after that is the same
770/// pipeline, and it has to stay the same. An issue spar filed for itself gets
771/// no easier a ride through triage than one a person opened, which is the whole
772/// reason the screening pass is one agent and this is two.
773fn work_issues(
774    agents: &[Agent],
775    cfg: &Config,
776    repo: &Repo,
777    first_wave: Vec<i64>,
778    plan_out: &Path,
779    results: &mut Vec<IssueRun>,
780) -> Result<()> {
781    let mut handled: BTreeSet<i64> = BTreeSet::new();
782    let mut leftover: BTreeSet<i64> = BTreeSet::new();
783    let mut queue: VecDeque<Wave> = VecDeque::from([Wave::first(first_wave)]);
784    let mut plans_written = 0usize;
785
786    // The first wave is what was asked for. A wave of follow-ups the previous
787    // one filed is folded back in as the absorb budget allows, and a wave of
788    // children extracted from a tracker's checklist joins the same run because
789    // it is named work rather than picked work. Every wave is triaged like
790    // anything else, so both agents still have to agree each one is worth
791    // doing.
792    while let Some(mut wave) = queue.pop_front() {
793        wave.numbers.retain(|n| !handled.contains(n));
794        if wave.numbers.is_empty() {
795            continue;
796        }
797        match wave.parent {
798            Some(tracker) => log!(
799                "working {} item(s) from the checklist in #{tracker}: {}",
800                wave.numbers.len(),
801                numbers(&wave.numbers)
802            ),
803            None if wave.absorbed > 0 => log!(
804                "absorbing {} newly filed issue(s): {}",
805                wave.numbers.len(),
806                numbers(&wave.numbers)
807            ),
808            None => {}
809        }
810        handled.extend(wave.numbers.iter().copied());
811
812        let fetched = match repo.fetch_issues(&wave.numbers) {
813            Ok(fetched) => fetched,
814            Err(e) => {
815                logdim!("could not read the next wave: {e}");
816                continue;
817            }
818        };
819        // Named for the order the plans were written in, so the first is
820        // plan.json exactly as before and no two waves overwrite each other.
821        let plan_path = if plans_written == 0 {
822            plan_out.to_path_buf()
823        } else {
824            plan_out.with_extension(format!("wave{plans_written}.json"))
825        };
826        plans_written += 1;
827        let plan = make_plan(agents, cfg, repo, &fetched, &plan_path)?;
828        act_on_plan(cfg, repo, &plan);
829
830        // No recursion: a child that triage calls a tracker in its turn is
831        // commented on and held like any other. `file_non_blocking` records
832        // what happened the last time this codebase let something multiply.
833        if cfg.loop_cfg.decompose_trackers && wave.parent.is_none() {
834            for item in plan.skipped.iter().filter(|i| i.tracker) {
835                let children = tracker::decompose(cfg, repo, item.issue);
836                if !children.is_empty() {
837                    queue.push_back(wave.child(item.issue, children));
838                }
839            }
840        }
841
842        let before = results.len();
843        for item in &plan.order {
844            let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
845                continue;
846            };
847            results.push(review::run_issue(agents, cfg, repo, item, issue));
848        }
849
850        // Whatever this wave filed becomes the next one, budget allowing.
851        let filed: Vec<i64> = results[before..]
852            .iter()
853            .flat_map(|r| r.filed.iter())
854            .filter_map(|url| review::filed_issue_number(url))
855            .filter(|n| !handled.contains(n))
856            .collect::<BTreeSet<_>>()
857            .into_iter()
858            .collect();
859        if filed.is_empty() {
860            continue;
861        }
862        match wave.absorbed < cfg.loop_cfg.absorb_new_issues {
863            true => queue.push_back(wave.absorb(filed)),
864            false => leftover.extend(filed),
865        }
866    }
867    if !leftover.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
868        log!(
869            "{} issue(s) filed in the last wave were left for a later run: {}",
870            leftover.len(),
871            numbers(&leftover.iter().copied().collect::<Vec<_>>())
872        );
873    }
874    Ok(())
875}
876
877/// One pass of the pipeline, and where it came from.
878struct Wave {
879    numbers: Vec<i64>,
880    /// Absorb rounds spent to reach it. A tracker's children inherit their
881    /// parent's, because extracting named work is not absorbing a follow-up:
882    /// `absorb_new_issues` is off by default, and spending it here would make
883    /// `decompose_trackers` silently do nothing.
884    absorbed: u32,
885    /// The tracker whose checklist this wave came out of.
886    parent: Option<i64>,
887}
888
889impl Wave {
890    fn first(numbers: Vec<i64>) -> Self {
891        Self {
892            numbers,
893            absorbed: 0,
894            parent: None,
895        }
896    }
897
898    fn child(&self, tracker: i64, numbers: Vec<i64>) -> Self {
899        Self {
900            numbers,
901            absorbed: self.absorbed,
902            parent: Some(tracker),
903        }
904    }
905
906    fn absorb(&self, numbers: Vec<i64>) -> Self {
907        Self {
908            numbers,
909            absorbed: self.absorbed + 1,
910            parent: None,
911        }
912    }
913}
914
915fn numbers(items: &[i64]) -> String {
916    items
917        .iter()
918        .map(|n| format!("#{n}"))
919        .collect::<Vec<_>>()
920        .join(", ")
921}
922
923fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
924    let mut cfg = config::load(common.config.as_deref())?;
925
926    if let Some(first) = &common.first {
927        if !cfg.has_agent(first) {
928            bail!("--first must be one of: {}", cfg.agent_names().join(", "));
929        }
930        cfg.first_implementor = first.clone();
931    }
932    if let Some(base) = &common.base {
933        cfg.loop_cfg.base_branch = base.clone();
934    }
935    if let Some(min) = common.min_number {
936        cfg.loop_cfg.min_number = min;
937    }
938    // Added to the config's, not in place of them. One is what this repository
939    // always wants and the other is what today wants, and a flag that silenced
940    // the standing set would be a trap: you would notice it the run after.
941    if let Some(extra) = common.instructions.as_deref().map(str::trim) {
942        if !extra.is_empty() {
943            let standing = cfg.loop_cfg.instructions.trim();
944            cfg.loop_cfg.instructions = if standing.is_empty() {
945                extra.to_string()
946            } else {
947                format!("{standing}\n{extra}")
948            };
949        }
950    }
951    if let Some(over) = overrides {
952        if let Some(v) = over.max_rounds {
953            if v == 0 {
954                bail!("--max-rounds must be at least 1");
955            }
956            cfg.loop_cfg.max_rounds = v;
957        }
958        if let Some(v) = over.auto_merge {
959            cfg.loop_cfg.auto_merge = v;
960        }
961        if let Some(v) = over.keep_worktrees {
962            cfg.loop_cfg.keep_worktrees = v;
963        }
964        if let Some(v) = over.worktrees {
965            cfg.loop_cfg.worktrees = v;
966        }
967        if let Some(v) = over.close_skipped {
968            cfg.loop_cfg.close_skipped = v;
969        }
970        if let Some(v) = over.absorb {
971            cfg.loop_cfg.absorb_new_issues = v;
972        }
973    }
974
975    let repo = Repo::open(&common.repo, &cfg)?;
976    if common.base.is_none() {
977        cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
978    }
979
980    let agents = agent::build(&cfg)?;
981    if let Some(warning) = agent::correlation_warning(&agents) {
982        logging::warn(warning);
983    }
984
985    // Sweep finished worktrees before starting, so they cannot accumulate.
986    for stale in repo.prune_worktrees(false) {
987        let what = if stale.starts_with("branch ") {
988            stale
989        } else {
990            format!("worktree {stale}")
991        };
992        logdim!("cleaned up finished {what}");
993    }
994
995    log!("repo {} base {}", repo.root().display(), cfg.base_branch());
996    log!(
997        "agents: {}",
998        agents
999            .iter()
1000            .map(|a| format!("{}={}", a.name(), a.spec.describe()))
1001            .collect::<Vec<_>>()
1002            .join(", ")
1003    );
1004    Ok((cfg, repo, agents))
1005}
1006
1007fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
1008    if !given.is_empty() {
1009        // Naming a number is the point, so a floor never overrides it.
1010        if min_number > 0 {
1011            let below: Vec<String> = given
1012                .iter()
1013                .filter(|n| **n < min_number)
1014                .map(|n| format!("#{n}"))
1015                .collect();
1016            if !below.is_empty() {
1017                logdim!(
1018                    "{} below the #{min_number} floor, taking them because you named them",
1019                    below.join(", ")
1020                );
1021            }
1022        }
1023        return Ok(given);
1024    }
1025    let found = repo.list_open_issues(limit, min_number)?;
1026    if found.is_empty() {
1027        log!("no open issues");
1028        return Ok(found);
1029    }
1030    log!(
1031        "no issues given, taking {} open: {}",
1032        found.len(),
1033        found
1034            .iter()
1035            .map(|n| format!("#{n}"))
1036            .collect::<Vec<_>>()
1037            .join(", ")
1038    );
1039    Ok(found)
1040}
1041
1042/// The bare `spar split`: every open issue and every open pull request, then
1043/// one screening call over both.
1044///
1045/// The first command that means both kinds when given nothing, so it says so
1046/// out loud. `--limit` applies to each list rather than to the two together: a
1047/// single budget shared across both would fill up on issues and starve the pull
1048/// requests. `min_number` applies because this is picked work rather than named
1049/// work.
1050///
1051/// One agent rules on the whole list in one call rather than two calls per
1052/// item, because two agent calls per item across a whole queue is the wrong
1053/// price for a question whose answer is usually no.
1054fn pick_for_split(
1055    agents: &[Agent],
1056    cfg: &Config,
1057    repo: &Repo,
1058    limit: usize,
1059    mode: &split::Mode,
1060) -> Result<Vec<(i64, ItemKind)>> {
1061    let issues = repo.list_open_issues(limit, cfg.loop_cfg.min_number)?;
1062    let prs = repo.list_open_prs(limit, cfg.loop_cfg.min_number)?;
1063    log!(
1064        "no numbers given, considering {} open issue(s) and {} open pull request(s)",
1065        issues.len(),
1066        prs.len()
1067    );
1068    if issues.is_empty() && prs.is_empty() {
1069        return Ok(Vec::new());
1070    }
1071
1072    let issue_rows = repo.open_issue_rows();
1073    let pr_rows = repo.open_pr_rows();
1074    let mut candidates: Vec<split::Candidate> = Vec::new();
1075    let mut already = 0usize;
1076
1077    for number in &issues {
1078        let Some(row) = issue_rows.iter().find(|i| i.number == *number) else {
1079            logdim!("could not read #{number}, leaving it alone");
1080            continue;
1081        };
1082        // Free here, because the body is already in hand. A pull request that
1083        // has been split is caught by `split_pr`, which reads its comments.
1084        if split::already_split(row.body_text()) && !mode.again {
1085            already += 1;
1086            continue;
1087        }
1088        candidates.push(split::Candidate::from_issue(row));
1089    }
1090    for number in &prs {
1091        match pr_rows.iter().find(|p| p.number == *number) {
1092            Some(row) => candidates.push(split::Candidate::from_pr(row)),
1093            None => logdim!("could not read PR #{number}, leaving it alone"),
1094        }
1095    }
1096    if already > 0 {
1097        log!("{already} issue(s) already split, skipped. --again reopens them.");
1098    }
1099    if candidates.is_empty() {
1100        return Ok(Vec::new());
1101    }
1102
1103    let agent = agent::find(agents, &cfg.first_implementor)?;
1104    log!(
1105        "screening {} item(s) with {}",
1106        candidates.len(),
1107        agent.name()
1108    );
1109    let verdicts = split::screen(agent, cfg, repo, &candidates)?;
1110
1111    let mut picked = Vec::new();
1112    for candidate in &candidates {
1113        match verdicts.iter().find(|v| v.item == candidate.number) {
1114            Some(v) if v.split => {
1115                log!("  split #{}: {}", candidate.number, v.reason.trim());
1116                picked.push((candidate.number, candidate.kind));
1117            }
1118            // Not a warning: no is the expected answer, and one line per item
1119            // saying so is the whole screen printed twice.
1120            Some(v) => logdim!("  leave #{} whole: {}", candidate.number, v.reason.trim()),
1121            None => logdim!("  no verdict for #{}, leaving it whole", candidate.number),
1122        }
1123    }
1124    if picked.is_empty() {
1125        log!("nothing worth splitting");
1126    }
1127    Ok(picked)
1128}
1129
1130/// Numbers split by what they actually name.
1131///
1132/// Issues and pull requests share one number sequence per repository, so a
1133/// person should not have to remember which command takes which. Both `run` and
1134/// `resume` sort the numbers themselves and route each one.
1135#[derive(Debug, Default)]
1136struct Sorted {
1137    issues: Vec<i64>,
1138    prs: Vec<i64>,
1139}
1140
1141fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
1142    let mut sorted = Sorted::default();
1143    for number in numbers {
1144        match repo.item_kind(*number)? {
1145            ItemKind::Issue => sorted.issues.push(*number),
1146            ItemKind::Pr => sorted.prs.push(*number),
1147        }
1148    }
1149    if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
1150        log!(
1151            "{} issue(s) and {} pull request(s) given",
1152            sorted.issues.len(),
1153            sorted.prs.len()
1154        );
1155    }
1156    Ok(sorted)
1157}
1158
1159fn make_plan(
1160    agents: &[Agent],
1161    cfg: &Config,
1162    repo: &Repo,
1163    issues: &[Issue],
1164    plan_out: &Path,
1165) -> Result<Plan> {
1166    let plan = triage::triage(agents, cfg, repo, issues)?;
1167
1168    std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
1169        .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
1170    log!("plan written to {}", plan_out.display());
1171
1172    for item in &plan.order {
1173        log!(
1174            "  do   #{} [{}/{}] {}",
1175            item.issue,
1176            item.complexity,
1177            item.risk,
1178            item.title
1179        );
1180    }
1181    for item in &plan.skipped {
1182        if item.tracker {
1183            log!(
1184                "  hold #{} (both reviewers: tracks work filed elsewhere)",
1185                item.issue
1186            );
1187        } else {
1188            log!("  skip #{} (both reviewers: not worth doing)", item.issue);
1189        }
1190    }
1191    for item in &plan.contested {
1192        log!("  ??   #{} contested, parked for you to decide", item.issue);
1193    }
1194    Ok(plan)
1195}
1196
1197/// Post the shared reasoning on every issue both agents declined, and close it
1198/// when the config says so. Contested issues are never touched.
1199///
1200/// A tracker is never closed, whatever `close_skipped` says. Declining to open
1201/// a pull request for an umbrella is right, and closing it does not follow from
1202/// that: its parts are still open, and the shared context and the alternatives
1203/// somebody recorded against are the reason the issue exists. spar closed a
1204/// real one as "not planned" while all three of its subtasks were open, which
1205/// is what this exists to stop.
1206fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
1207    for item in &plan.skipped {
1208        let body = review::skip_comment(item, &repo.style);
1209        let close = cfg.loop_cfg.close_skipped && !item.tracker;
1210        let outcome = if close {
1211            repo.close_issue(item.issue, &body)
1212        } else {
1213            repo.comment_issue(item.issue, &body)
1214        };
1215        match outcome {
1216            Ok(()) if close => log!("  closed #{}", item.issue),
1217            Ok(()) if item.tracker => {
1218                log!(
1219                    "  left #{} open, it tracks work filed elsewhere",
1220                    item.issue
1221                )
1222            }
1223            Ok(()) => {}
1224            Err(e) => logdim!("could not update #{}: {e}", item.issue),
1225        }
1226    }
1227}
1228
1229// ---------------------------------------------------------------------------
1230// Subcommands
1231// ---------------------------------------------------------------------------
1232
1233fn cmd_scrub_filter() -> Result<i32> {
1234    let mut input = String::new();
1235    std::io::stdin()
1236        .read_to_string(&mut input)
1237        .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
1238    let out = style::scrub(&input, &crate::repo::style_from_env());
1239    let mut stdout = std::io::stdout();
1240    stdout
1241        .write_all(out.as_bytes())
1242        .and_then(|_| stdout.write_all(b"\n"))
1243        .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
1244    Ok(0)
1245}
1246
1247fn cmd_clean(
1248    repo_path: &Path,
1249    config_path: Option<&Path>,
1250    all: bool,
1251    pr_state: bool,
1252) -> Result<i32> {
1253    let cfg = config::load(config_path)?;
1254    let repo = Repo::open(repo_path, &cfg)?;
1255    let mut removed = repo.prune_worktrees(all);
1256    removed.extend(repo.prune_state());
1257    if pr_state {
1258        removed.extend(repo.prune_pr_state(None));
1259    }
1260    if removed.is_empty() {
1261        if repo.write_summary().failed > 0 {
1262            println!("nothing removed");
1263        } else {
1264            println!("nothing to clean");
1265        }
1266    } else {
1267        for item in removed {
1268            println!("removed {item}");
1269        }
1270    }
1271    Ok(report_writes(&repo))
1272}
1273
1274/// Post a review that was produced earlier and not sent.
1275fn cmd_post(
1276    prs: &[i64],
1277    repo_path: &Path,
1278    config_path: Option<&Path>,
1279    file: Option<&Path>,
1280    dry_run: bool,
1281) -> Result<i32> {
1282    let cfg = config::load(config_path)?;
1283    let repo = Repo::open(repo_path, &cfg)?;
1284
1285    if file.is_some() && prs.len() > 1 {
1286        bail!("--file posts one review, so give it one pull request number");
1287    }
1288
1289    let mut failed = false;
1290    for number in prs {
1291        let text = match file {
1292            Some(path) => std::fs::read_to_string(path)
1293                .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
1294            None => match repo.read_pending_comment(*number) {
1295                Some(text) => text,
1296                None => {
1297                    logging::error(format!(
1298                        "no saved review for PR #{number}. `spar review {number} --dry-run` \
1299                         produces one, or pass --file."
1300                    ));
1301                    failed = true;
1302                    continue;
1303                }
1304            },
1305        };
1306        if text.trim().is_empty() {
1307            logging::error(format!("the saved review for PR #{number} is empty"));
1308            failed = true;
1309            continue;
1310        }
1311        if dry_run {
1312            println!("\n{}\n", text.trim());
1313            log!("would post the above to PR #{number}");
1314            continue;
1315        }
1316        // Through the style gate like anything else spar sends, so an edit that
1317        // reintroduces a banned dash is caught rather than published.
1318        match repo.comment_pr(*number, &text) {
1319            Ok(()) => log!("posted to PR #{number}"),
1320            Err(e) => {
1321                logging::error(format!("could not post to PR #{number}: {e}"));
1322                failed = true;
1323            }
1324        }
1325    }
1326    Ok(i32::from(failed).max(report_writes(&repo)))
1327}
1328
1329/// Append the settings a config does not mention, commented out.
1330///
1331/// Append only by design. Rewriting somebody's config to insert options would
1332/// take their comments and their ordering with it, and `--force` already exists
1333/// for anyone who wants the generated file back.
1334fn cmd_init_update(out: &Path) -> Result<i32> {
1335    let text = std::fs::read_to_string(out)
1336        .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
1337    // Refuse to append to something that does not parse, rather than making a
1338    // broken config longer.
1339    config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
1340
1341    let unset = config::unmentioned_options(&text);
1342    if unset.is_empty() {
1343        println!("{} already mentions every setting.", out.display());
1344        return Ok(0);
1345    }
1346
1347    let mut block = String::new();
1348    if !text.ends_with('\n') {
1349        block.push('\n');
1350    }
1351    block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
1352    block.push_str("# shown at their defaults. Uncomment one to change it.\n");
1353    let mut section = "";
1354    for option in &unset {
1355        if option.section != section {
1356            section = option.section;
1357            block.push_str(&format!("\n# [{section}]\n"));
1358        }
1359        // With the same note `spar init` writes. A config that gained a setting
1360        // this way used to gain a bare line and nothing saying what it was for,
1361        // which is the half of the setting that matters when you are reading it
1362        // for the first time.
1363        block.push('\n');
1364        block.push_str(&wrap_comment(note_for(&option.key)));
1365        block.push_str(&format!("# {} = {}\n", option.key, option.default));
1366    }
1367
1368    use std::io::Write;
1369    std::fs::OpenOptions::new()
1370        .append(true)
1371        .open(out)
1372        .and_then(|mut f| f.write_all(block.as_bytes()))
1373        .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
1374
1375    println!(
1376        "added {} setting(s) to {} as comments",
1377        unset.len(),
1378        out.display()
1379    );
1380    Ok(0)
1381}
1382
1383fn cmd_init(out: &Path, force: bool) -> Result<i32> {
1384    if out.exists() && !force {
1385        logging::error(format!(
1386            "{} already exists. `--update` appends any settings it does not mention, \
1387             `--force` overwrites it.",
1388            out.display()
1389        ));
1390        return Ok(1);
1391    }
1392
1393    let presets = config::available_presets();
1394    if presets.is_empty() {
1395        bail!("no presets available, which should be impossible in a released build");
1396    }
1397
1398    let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
1399    for name in &presets {
1400        let raw = config::load_preset(name)?;
1401        // A preset that will not build is a broken preset, not an uninstalled
1402        // CLI. Skipping it silently reported it as "missing" and sent people
1403        // looking for an install problem that was not there.
1404        let mut spec: config::AgentSpec = match raw
1405            .as_table()
1406            .cloned()
1407            .ok_or_else(|| spar_err!("not a table"))
1408            .and_then(|t| {
1409                toml::Value::Table(t)
1410                    .try_into()
1411                    .map_err(|e| spar_err!("{e}"))
1412            }) {
1413            Ok(spec) => spec,
1414            Err(e) => {
1415                println!("  BROKEN   {name:10} {}", e.first_line());
1416                continue;
1417            }
1418        };
1419        spec.name = name.clone();
1420        match Agent::new(spec.clone()).resolve_bin() {
1421            Ok(path) => {
1422                println!("  found    {name:10} {}", path.display());
1423                found.push((name.clone(), path.to_path_buf(), spec));
1424            }
1425            Err(_) => println!("  missing  {name}"),
1426        }
1427    }
1428
1429    if found.len() < 2 {
1430        logging::error(format!(
1431            "need two agent CLIs, found {}. Install another, or write {} by hand using the \
1432             presets as a reference.",
1433            found.len(),
1434            out.display()
1435        ));
1436        return Ok(1);
1437    }
1438
1439    // Prefer a pair that cannot share blind spots, if one is available.
1440    let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
1441    if found.len() > 2 {
1442        log!(
1443            "{} agents available, picking {} and {}. Edit {} to change.",
1444            found.len(),
1445            chosen[0].0,
1446            chosen[1].0,
1447            out.display()
1448        );
1449    }
1450
1451    let mut text = String::from(
1452        "# Generated by `spar init`. Each agent inherits a command template from a\n\
1453         # built in preset; anything set here overrides it.\n\
1454         #\n\
1455         # Commented lines are the other options, each with a working value.\n\
1456         # Uncomment one to change it.\n\n",
1457    );
1458    for (name, _, spec) in &chosen {
1459        text.push_str(&agent_block(name, spec));
1460    }
1461    text.push_str(&settings_block(&chosen[0].0));
1462
1463    std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
1464    println!("\nwrote {}", out.display());
1465    println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
1466    Ok(0)
1467}
1468
1469/// An agent's stand in, under the agent it stands in for.
1470///
1471/// Never counted against `doctor`'s exit code, deliberately. A fallback that is
1472/// not installed does not stop a run either, and a check that disagrees with
1473/// the runtime teaches people to ignore it.
1474fn report_fallback(agent: &Agent) {
1475    let Some(backup) = agent.fallback() else {
1476        return;
1477    };
1478    match backup.resolve_bin() {
1479        Ok(bin) => println!(
1480            "        fallback    {}  ({})",
1481            bin.display(),
1482            backup.spec.describe()
1483        ),
1484        Err(_) => println!(
1485            "        fallback    {} not found, so it will not stand in. Set {} to its path.",
1486            backup.program(),
1487            backup.env_key()
1488        ),
1489    }
1490}
1491
1492/// One settable option: whether the generated config leaves it commented out,
1493/// its key, and the note beside it.
1494///
1495/// The value is deliberately absent. Every value comes from the defaults
1496/// themselves, because a value typed in here is a second copy of a number that
1497/// lives somewhere else, and the second copy is the one that goes stale. This
1498/// one did: the generated config offered a title budget of 90, a summary of
1499/// 200, a detail of 320, a body of 900 and an issue body of 4000, long after
1500/// those became 140, 2000, 6000, 8000 and 20000. Uncommenting a line to see
1501/// what it did cut every comment spar posts to a fifth of its length.
1502type Setting = (bool, &'static str, &'static str);
1503
1504const LOOP_OPTIONS: &[Setting] = &[
1505    (false, "max_rounds", "In the custody loop, review rounds one invocation may spend asking for changes. A full-budget run may then use one extra closing pass. In `spar review`, this selects up to three phases: independent review, cross-adjudication, and rebuttal. Resuming a custody run grants a fresh budget, so this is not a lifetime cap."),
1506    (false, "auto_merge", "Merge when no blocking findings remain. Off on purpose: two models agreeing is not the same as being right, and neither carries the consequences."),
1507    (false, "first_implementor", "Which agent takes the first pass. The other one reviews it."),
1508    (false, "worktrees", "Isolate each issue in its own git worktree. Set false to work in the main checkout."),
1509    (false, "close_skipped", "Close an issue both reviewers declined, after posting the shared reasoning. A tracking issue is left open whatever this says."),
1510    (false, "followups", "Where a follow-up goes. issues files them, local writes .spar/followups.md and leaves the tracker alone, none drops them. `spar followup` works that file."),
1511    (true, "file_non_blocking", "File a non-blocking finding as a follow-up. Off, because not gating a merge is not the same as deserving somebody's triage queue."),
1512    (true, "max_followups", "Most follow-ups one run may record before it stops and says what it dropped. A backstop, not a target. `spar followup` is bounded by --limit instead."),
1513    (true, "max_split_parts", "Most parts `spar split` will make out of one issue or pull request. A backstop against a queue nobody asked for, not a target, and what it holds back is said out loud. A part is never itself split, so if one is still too big, `spar split <part>` is one command away."),
1514    (true, "keep_worktrees", "Keep worktrees after a run, for inspection."),
1515    (true, "min_number", "Ignore issues and pull requests numbered below this when spar picks for itself. 0 is no floor, and a number you name explicitly is always honoured."),
1516    (true, "parallel_triage", "Ask both agents to triage at once. They only read during triage, so there is nothing to serialise."),
1517    (true, "absorb_new_issues", "Waves of newly filed follow-ups to fold back into this run rather than leaving them for the next one. Multiplies what a run costs."),
1518    (true, "decompose_trackers", "Turn the checklist in a tracking issue into issues, link each item to the one covering it, tick an item off when its issue closes, and work the children in this run. Only ever acts on markdown task list items, so it is opt in per issue as well. `spar triage` prints what it would do without writing any of it."),
1519    (true, "max_tracker_children", "Most unchecked items from one tracker's checklist that one run will act on. A cap, not a target, and what it left is named out loud."),
1520    (true, "file_nits", "File nits as follow-ups too. Off, because a filed nit is somebody else's notification."),
1521    (true, "base_branch", "Only a fallback. Whatever origin/HEAD points at wins when it resolves."),
1522    (true, "branch_prefix", "Namespace the branches spar creates, for example \"spar/\". Without it they are issue-N, pr-N, and split-N-I with a suffix on repeated split names."),
1523    (true, "state_store", "Where resume state is kept. local uses .spar/state and keeps it off the pull request."),
1524    (true, "drafts", "Whether a pull request starts as a draft. until_approved opens one and marks it ready when the review converges, which is what the draft was saying while two agents were still arguing about it. always opens one and leaves it, and cannot be combined with auto_merge."),
1525    (true, "instructions", "Extra instructions handed to both agents with every request, for what this repository always wants that spar has no setting for. --instructions adds to this for one run."),
1526    (true, "max_issue_chars", "Most of one issue body that reaches a prompt. Sized so nothing a person wrote is cut, and a cut is said out loud when it happens."),
1527    (true, "max_triage_chars", "Most every issue body together may add to one triage prompt, or every recorded follow-up in one screening prompt. Past it, whole items wait for the next run rather than all of them losing their tails."),
1528    (true, "checkin_trust", "Whose comments `spar checkin` will act on. write is anybody GitHub says can write to this repository, which is the default because acting on a comment means pushing a commit to somebody's branch. anyone answers everyone, and still only changes code when both agents agree."),
1529    (true, "checkin_resolve", "Mark a review thread resolved when spar made the change it asked for. A thread spar disagreed with is left open whatever this says."),
1530    (true, "max_checkin_comments", "Most unanswered comments spar will answer on one pull request in a run. A backstop against a long argument being read back to somebody, not a target."),
1531];
1532
1533const STYLE_OPTIONS: &[Setting] = &[
1534    (false, "ban_em_dash", "Strip em-dashes and en-dashes from everything spar posts, then refuse to post text that still has one."),
1535    (false, "ban_ai_attribution", "Strip mentions of the tooling, and Co-Authored-By trailers, from everything spar posts."),
1536    (false, "terse", "Hold model prose to a length budget. false removes the valves entirely."),
1537    (true, "pr_comments", "How much of its own working spar narrates into a pull request thread. outcome is one comment at the end, rounds is an audit trail, none never comments at all."),
1538    (true, "max_title_chars", "A finding, issue, or pull request title. Never ellipsised: a title ending in three dots reads as broken."),
1539    (true, "max_summary_chars", "A one line verdict, or a refutation's argument."),
1540    (true, "max_detail_chars", "A blocking finding's explanation, as it appears in the pull request thread."),
1541    (true, "max_body_chars", "A pull request body."),
1542    (true, "max_issue_body_chars", "A filed issue's body. Far larger on purpose: an issue is picked up cold months later. Fenced code blocks are never truncated and never count against it."),
1543];
1544
1545/// The `[loop]` and `[style]` blocks of a generated config.
1546///
1547/// Safety valves, not editors: the length budgets here are sized so real
1548/// content is never touched, which is why they read as large numbers.
1549fn settings_block(first_implementor: &str) -> String {
1550    let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1551        .into_iter()
1552        .map(|option| (option.key, option.default))
1553        .collect();
1554    // first_implementor has no default: it is whichever agent was written
1555    // first, and until there is a config there is no answer to give.
1556    let value = |key: &str| match key {
1557        "first_implementor" => format!("\"{first_implementor}\""),
1558        other => defaults.get(other).cloned().unwrap_or_default(),
1559    };
1560
1561    let mut out = String::from("[loop]\n");
1562    out.push_str(&option_lines(LOOP_OPTIONS, &value));
1563    out.push_str(concat!(
1564        "\n[loop.effort_schedule]\n",
1565        "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1566        "# these are examples rather than defaults. Left out, each agent uses\n",
1567        "# the effort its own block asked for.\n",
1568        "# round_1 = \"high\"   # the deep first review\n",
1569        "# rest    = \"low\"    # later rounds and the closing pass\n\n",
1570    ));
1571    out.push_str("[style]\n");
1572    out.push_str(&option_lines(STYLE_OPTIONS, &value));
1573    out
1574}
1575
1576/// Option lines with their notes lined up in a column, a long note wrapping
1577/// onto continuation lines that stay in the column rather than running off the
1578/// edge or restarting at the margin.
1579fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1580    let mut out = String::new();
1581    for (commented, key, note) in options {
1582        if !out.is_empty() {
1583            out.push('\n');
1584        }
1585        out.push_str(&wrap_comment(note));
1586        let lead = if *commented { "# " } else { "" };
1587        out.push_str(&format!("{lead}{key} = {}\n", value(key)));
1588    }
1589    out
1590}
1591
1592/// One prerequisite check: a label and something that either reports a version
1593/// or explains what is missing.
1594type Probe = Box<dyn Fn() -> Result<String>>;
1595
1596/// One agent's block, with the options commented out beside a working value.
1597///
1598/// The values come from the preset rather than from here, so a CLI that adds a
1599/// model is a file edit. They are hints only: nothing validates against them,
1600/// because a stale list that refused a model which actually works would be
1601/// worse than no hint at all.
1602fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1603    let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1604
1605    // Only what the preset has hints for. An option with none used to be
1606    // written as `# effort = "..."`, and a placeholder is not a working value:
1607    // the line fails the moment somebody takes the file at its word and
1608    // uncomments it. Cursor has no effort setting at all, so for that agent the
1609    // line should not exist rather than exist and be wrong.
1610    //
1611    // The first entry of each list is the one written as the suggested value,
1612    // which is why the presets put the sensible default there rather than in
1613    // whatever order a CLI's help happens to print.
1614    // Once, above both. The preset's note is about the pair, and repeating it
1615    // under each put a sentence about models underneath the effort line.
1616    if let Some(extra) = &spec.options_note {
1617        if !spec.models.is_empty() || !spec.efforts.is_empty() {
1618            out.push('\n');
1619            out.push_str(&wrap_comment(extra));
1620        }
1621    }
1622    for (key, choices) in [("model", &spec.models), ("effort", &spec.efforts)] {
1623        let Some(suggested) = choices.first() else {
1624            continue;
1625        };
1626        let mut note = format!("Omit {key} to use the CLI's own default.");
1627        if choices.len() > 1 {
1628            note.push_str(&format!(" One of: {}.", choices.join(" | ")));
1629        }
1630        out.push('\n');
1631        out.push_str(&wrap_comment(&note));
1632        out.push_str(&format!("# {key} = \"{suggested}\"\n"));
1633    }
1634
1635    // The value from the spec, not a number typed here, for the reason the
1636    // [loop] block learned: a second copy of a default is the one that goes
1637    // stale.
1638    out.push('\n');
1639    out.push_str(&wrap_comment(
1640        "Seconds one call may take before spar gives up. A timeout costs the whole call and is \
1641         never retried, so err long.",
1642    ));
1643    out.push_str(&format!("# timeout = {}\n", spec.timeout));
1644
1645    // Anything but this agent's own preset: a CLI that has just refused is not
1646    // a stand in for itself.
1647    let backup = if name == "cursor" { "gemini" } else { "cursor" };
1648    out.push('\n');
1649    out.push_str(&wrap_comment(
1650        "A stand in for when this CLI refuses, stalls, or runs out of quota. It answers in place \
1651         of this agent, never alongside it.",
1652    ));
1653    out.push_str(&format!(
1654        "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1655    ));
1656
1657    // The rest of what an agent block takes defines a CLI rather than tunes
1658    // one, so it is pointed at rather than offered: a generated file that
1659    // invites somebody to edit `command` or `output` on a working preset is
1660    // offering them a way to break it.
1661    out.push('\n');
1662    out.push_str(&wrap_comment(
1663        "command, output, search_paths and the rest are in spar.example.toml, for pairing a CLI \
1664         that has no preset.",
1665    ));
1666    out.push('\n');
1667    out
1668}
1669
1670/// What `spar init` says about an option, for `--update` to say too.
1671///
1672/// Empty for one with nothing written about it, and for the effort schedule,
1673/// whose two keys are examples rather than settings and are described by the
1674/// stanza they sit in rather than one at a time.
1675fn note_for(key: &str) -> &'static str {
1676    LOOP_OPTIONS
1677        .iter()
1678        .chain(STYLE_OPTIONS)
1679        .find(|(_, name, _)| *name == key)
1680        .map(|(_, _, note)| *note)
1681        .unwrap_or("")
1682}
1683
1684/// Wrap a note across comment lines so a long one does not run off the edge.
1685fn wrap_comment(text: &str) -> String {
1686    const WIDTH: usize = 76;
1687    let mut out = String::new();
1688    let mut line = String::from("#");
1689    for word in text.split_whitespace() {
1690        if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1691            out.push_str(&line);
1692            out.push('\n');
1693            line = String::from("#");
1694        }
1695        line.push(' ');
1696        line.push_str(word);
1697    }
1698    if line.len() > 1 {
1699        out.push_str(&line);
1700        out.push('\n');
1701    }
1702    out
1703}
1704
1705fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1706    let mut ok = true;
1707
1708    let probes: Vec<(&str, Probe)> = vec![
1709        (
1710            "git",
1711            Box::new(|| {
1712                proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1713                    .map(|s| first_line(&s))
1714            }),
1715        ),
1716        (
1717            "gh",
1718            Box::new(|| {
1719                proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1720                    .map(|s| first_line(&s))
1721            }),
1722        ),
1723        (
1724            "gh auth",
1725            Box::new(|| {
1726                let out = proc::exec(
1727                    &["gh".into(), "auth".into(), "status".into()],
1728                    &ExecOpts::new().check(false).timeout_secs(60),
1729                )?;
1730                let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1731                if out.ok() {
1732                    Ok(first_line(&text))
1733                } else {
1734                    Err(spar_err!("not authenticated. Run `gh auth login`."))
1735                }
1736            }),
1737        ),
1738    ];
1739
1740    for (label, probe) in probes {
1741        match probe() {
1742            Ok(detail) => println!("  ok    {label:12} {detail}"),
1743            Err(e) => {
1744                println!("  FAIL  {label:12} {}", e.first_line());
1745                ok = false;
1746            }
1747        }
1748    }
1749
1750    let found = config::find_config(config_path)?;
1751    let Some(path) = found else {
1752        println!("\n  no spar.toml found. Run `spar init` to generate one.");
1753        println!(
1754            "  presets available: {}",
1755            config::available_presets().join(", ")
1756        );
1757        return Ok(if ok { 0 } else { 1 });
1758    };
1759
1760    println!("\n  config: {}", path.display());
1761    let cfg = match config::load(Some(&path)) {
1762        Ok(cfg) => cfg,
1763        Err(e) => {
1764            println!("  FAIL  config       {e}");
1765            return Ok(1);
1766        }
1767    };
1768
1769    // Kept apart from `ok`: a missing gh says nothing about whether the two
1770    // agents are the same CLI, and must not silence the warning below.
1771    let mut resolved = Vec::new();
1772    for spec in &cfg.agents {
1773        let agent = Agent::new(spec.clone());
1774        match agent.resolve_bin() {
1775            Ok(bin) => {
1776                println!(
1777                    "  ok    {:12} {}  ({})",
1778                    spec.name,
1779                    bin.display(),
1780                    spec.describe()
1781                );
1782                report_fallback(&agent);
1783                resolved.push(agent);
1784            }
1785            Err(e) => {
1786                println!("  FAIL  {:12} {}", spec.name, e.first_line());
1787                ok = false;
1788            }
1789        }
1790    }
1791
1792    if resolved.len() == cfg.agents.len() {
1793        if let Some(warning) = agent::correlation_warning(&resolved) {
1794            println!("\n  WARNING  {warning}");
1795        }
1796    }
1797
1798    println!(
1799        "\n  settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1800        cfg.loop_cfg.max_rounds,
1801        cfg.loop_cfg.auto_merge,
1802        cfg.loop_cfg.worktrees,
1803        cfg.loop_cfg.followups,
1804        cfg.style.terse
1805    );
1806    // What somebody upgrading wants to know. `spar init` refuses to touch an
1807    // existing config, so without this there is no way to learn that a release
1808    // added a setting short of reading the source.
1809    if let Ok(text) = std::fs::read_to_string(&path) {
1810        let unset = config::unmentioned_options(&text);
1811        if !unset.is_empty() {
1812            println!(
1813                "\n  {} setting(s) this config does not mention, all at their defaults:",
1814                unset.len()
1815            );
1816            for option in &unset {
1817                println!(
1818                    "      [{}] {} = {}",
1819                    option.section, option.key, option.default
1820                );
1821            }
1822            println!(
1823                "  `spar init --update {}` appends them as comments.",
1824                path.display()
1825            );
1826        }
1827    }
1828
1829    println!(
1830        "{}",
1831        if ok {
1832            "\nready"
1833        } else {
1834            "\nmissing prerequisites"
1835        }
1836    );
1837    Ok(if ok { 0 } else { 1 })
1838}
1839
1840fn first_line(text: &str) -> String {
1841    text.trim().lines().next().unwrap_or("").trim().to_string()
1842}
1843
1844// ---------------------------------------------------------------------------
1845// Reporting
1846// ---------------------------------------------------------------------------
1847
1848fn report(results: &[IssueRun], cfg: &Config, repo: &Repo) -> i32 {
1849    println!("\n{}", "=".repeat(60));
1850    for r in results {
1851        println!(
1852            "#{:<5} {:<10} rounds={} {}",
1853            r.issue,
1854            r.status.to_string(),
1855            r.rounds,
1856            r.pr.as_deref().unwrap_or("")
1857        );
1858        for note in &r.notes {
1859            println!("       {}", first_line(note));
1860        }
1861        for url in &r.filed {
1862            println!("       filed {url}");
1863        }
1864        for dispute in &r.disputes {
1865            println!(
1866                "       disputed: {}",
1867                report_item(&dispute.title, &dispute.file)
1868            );
1869        }
1870        for finding in &r.noted {
1871            println!(
1872                "       noted, not blocking: {}",
1873                report_item(&finding.title, &finding.file)
1874            );
1875        }
1876    }
1877    println!("{}", "=".repeat(60));
1878
1879    if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1880        println!("\nApproved PRs are waiting on you to merge.");
1881    }
1882    let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1883    if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1884        println!(
1885            "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1886             Set followups = \"issues\" to file them."
1887        );
1888    }
1889    let issue_exit = i32::from(!results.iter().all(IssueRun::succeeded));
1890    issue_exit.max(report_writes(repo))
1891}
1892
1893/// A failed write makes the final status non-zero, including a partial failure.
1894/// Independent writes still finish first, so one failure does not discard work
1895/// that another item can land. The non-zero status lets automation retry what
1896/// was missed.
1897fn report_writes(repo: &Repo) -> i32 {
1898    let writes = repo.write_summary();
1899    if let Some(line) = write_summary_line(writes) {
1900        println!("\n{line}");
1901    }
1902    write_exit_code(writes)
1903}
1904
1905fn write_summary_line(writes: WriteSummary) -> Option<String> {
1906    (writes.attempted > 0).then(|| {
1907        format!(
1908            "writes: {} attempted, {} succeeded, {} failed",
1909            writes.attempted,
1910            writes.succeeded(),
1911            writes.failed
1912        )
1913    })
1914}
1915
1916fn write_exit_code(writes: WriteSummary) -> i32 {
1917    i32::from(writes.failed > 0)
1918}
1919
1920fn report_item(title: &str, file: &str) -> String {
1921    match file.trim() {
1922        "" => title.to_string(),
1923        location => format!("{title} ({location})"),
1924    }
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929    use super::*;
1930    use clap::CommandFactory;
1931
1932    #[test]
1933    fn report_items_include_their_location() {
1934        assert_eq!(
1935            "Same title (src/a.rs:10)",
1936            report_item("Same title", "src/a.rs:10")
1937        );
1938        assert_eq!("General point", report_item("General point", ""));
1939    }
1940
1941    #[test]
1942    fn every_attempted_write_failing_is_a_failed_run() {
1943        let writes = WriteSummary {
1944            attempted: 3,
1945            failed: 3,
1946        };
1947
1948        assert_eq!(1, write_exit_code(writes));
1949        assert_eq!(
1950            Some("writes: 3 attempted, 0 succeeded, 3 failed".to_string()),
1951            write_summary_line(writes)
1952        );
1953    }
1954
1955    #[test]
1956    fn a_partial_write_failure_is_reported_after_the_run() {
1957        let writes = WriteSummary {
1958            attempted: 3,
1959            failed: 1,
1960        };
1961
1962        assert_eq!(1, write_exit_code(writes));
1963        assert_eq!(2, writes.succeeded());
1964    }
1965
1966    #[test]
1967    fn the_parser_is_internally_consistent() {
1968        Cli::command().debug_assert();
1969    }
1970
1971    #[test]
1972    fn quiet_is_accepted_before_or_after_the_subcommand() {
1973        for argv in [
1974            vec!["spar", "--quiet", "run", "42"],
1975            vec!["spar", "run", "42", "--quiet"],
1976            vec!["spar", "resume", "--quiet"],
1977            vec!["spar", "init", "-q"],
1978        ] {
1979            assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1980        }
1981        assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1982    }
1983
1984    #[test]
1985    fn several_issue_numbers_are_accepted() {
1986        let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1987        match cli.command {
1988            Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1989            other => panic!("{other:?}"),
1990        }
1991    }
1992
1993    #[test]
1994    fn issue_numbers_and_flags_can_be_interleaved() {
1995        let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1996        match cli.command {
1997            Command::Run {
1998                issues, loop_flags, ..
1999            } => {
2000                assert_eq!(vec![42, 51], issues);
2001                assert!(loop_flags.auto_merge);
2002            }
2003            other => panic!("{other:?}"),
2004        }
2005    }
2006
2007    #[test]
2008    fn every_command_that_reads_a_config_accepts_one() {
2009        for argv in [
2010            vec!["spar", "run", "42"],
2011            vec!["spar", "triage"],
2012            vec!["spar", "resume"],
2013            vec!["spar", "followup"],
2014            vec!["spar", "checkin"],
2015            vec!["spar", "split"],
2016            vec!["spar", "clean"],
2017            vec!["spar", "doctor"],
2018        ] {
2019            let mut full = argv.clone();
2020            full.extend(["--config", "other.toml"]);
2021            let cli = Cli::parse_from(&full);
2022            let config = match cli.command {
2023                Command::Run { common, .. }
2024                | Command::Triage { common, .. }
2025                | Command::Resume { common, .. }
2026                | Command::Followup { common, .. }
2027                | Command::Split { common, .. }
2028                | Command::Checkin { common, .. } => common.config,
2029                Command::Clean { config, .. } | Command::Doctor { config } => config,
2030                other => panic!("{other:?}"),
2031            };
2032            assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
2033        }
2034    }
2035
2036    #[test]
2037    fn auto_merge_is_off_unless_asked_for() {
2038        let cli = Cli::parse_from(["spar", "run"]);
2039        match cli.command {
2040            Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
2041            other => panic!("{other:?}"),
2042        }
2043    }
2044
2045    /// The flag reaches resume as well as run, which is what makes "stop, edit
2046    /// the config, carry on with something extra to say" a thing you can do.
2047    #[test]
2048    fn every_command_that_reads_a_config_takes_instructions() {
2049        // `followup` is given no number, because it takes none: its entries
2050        // have no identity a person could type.
2051        for argv in [
2052            vec!["spar", "run", "7", "--instructions", "Do not wait for CI."],
2053            vec![
2054                "spar",
2055                "triage",
2056                "7",
2057                "--instructions",
2058                "Do not wait for CI.",
2059            ],
2060            vec![
2061                "spar",
2062                "resume",
2063                "7",
2064                "--instructions",
2065                "Do not wait for CI.",
2066            ],
2067            vec![
2068                "spar",
2069                "review",
2070                "7",
2071                "--instructions",
2072                "Do not wait for CI.",
2073            ],
2074            vec!["spar", "followup", "--instructions", "Do not wait for CI."],
2075            vec![
2076                "spar",
2077                "checkin",
2078                "7",
2079                "--instructions",
2080                "Do not wait for CI.",
2081            ],
2082            vec![
2083                "spar",
2084                "split",
2085                "7",
2086                "--instructions",
2087                "Do not wait for CI.",
2088            ],
2089        ] {
2090            let parsed = Cli::parse_from(&argv);
2091            let common = match parsed.command {
2092                Command::Run { common, .. }
2093                | Command::Triage { common, .. }
2094                | Command::Resume { common, .. }
2095                | Command::Review { common, .. }
2096                | Command::Followup { common, .. }
2097                | Command::Split { common, .. }
2098                | Command::Checkin { common, .. } => common,
2099                other => panic!("{other:?}"),
2100            };
2101            assert_eq!(
2102                Some("Do not wait for CI."),
2103                common.instructions.as_deref(),
2104                "{argv:?}"
2105            );
2106        }
2107    }
2108
2109    #[test]
2110    fn the_two_close_skipped_flags_are_mutually_exclusive() {
2111        assert!(
2112            Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
2113        );
2114    }
2115
2116    /// Only `run` triages, so only `run` can decline an issue. Accepting the
2117    /// flag on `resume` would silently do nothing.
2118    #[test]
2119    fn close_skipped_is_offered_only_where_it_means_something() {
2120        assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
2121        assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
2122        // `followup` triages what it files, so it can decline it too.
2123        assert!(Cli::try_parse_from(["spar", "followup", "--close-skipped"]).is_ok());
2124        assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
2125        assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
2126        assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
2127    }
2128
2129    /// An entry in the follow-up queue has no number and its title is prose, so
2130    /// a number on the command line could only be silently ignored.
2131    #[test]
2132    fn followup_takes_no_numbers() {
2133        assert!(Cli::try_parse_from(["spar", "followup"]).is_ok());
2134        assert!(Cli::try_parse_from(["spar", "followup", "42"]).is_err());
2135    }
2136
2137    /// `--screen-only` stops before `--file-only` does, so asking for both says
2138    /// nothing about where to stop.
2139    #[test]
2140    fn the_two_stopping_points_are_mutually_exclusive() {
2141        assert!(Cli::try_parse_from(["spar", "followup", "--screen-only"]).is_ok());
2142        assert!(Cli::try_parse_from(["spar", "followup", "--file-only"]).is_ok());
2143        assert!(Cli::try_parse_from(["spar", "followup", "--screen-only", "--file-only"]).is_err());
2144    }
2145
2146    #[test]
2147    fn the_close_skipped_pair_resolves_to_a_tristate() {
2148        let read = |argv: &[&str]| match Cli::parse_from(argv).command {
2149            Command::Run { triage_flags, .. } => {
2150                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
2151                    (true, _) => Some(true),
2152                    (_, true) => Some(false),
2153                    _ => None,
2154                }
2155            }
2156            other => panic!("{other:?}"),
2157        };
2158        assert_eq!(None, read(&["spar", "run"]));
2159        assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
2160        assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
2161    }
2162
2163    #[test]
2164    fn the_default_limit_is_twenty() {
2165        let cli = Cli::parse_from(["spar", "run"]);
2166        match cli.command {
2167            Command::Run { common, .. } => assert_eq!(20, common.limit),
2168            other => panic!("{other:?}"),
2169        }
2170    }
2171
2172    #[test]
2173    fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
2174        assert!(matches!(
2175            Cli::parse_from(["spar", "scrub-filter"]).command,
2176            Command::ScrubFilter
2177        ));
2178        let help = Cli::command().render_long_help().to_string();
2179        assert!(
2180            !help.contains("scrub-filter"),
2181            "it is plumbing, not a command"
2182        );
2183    }
2184
2185    #[test]
2186    fn review_takes_pr_numbers_and_a_dry_run() {
2187        let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
2188        match cli.command {
2189            Command::Review { items, dry_run, .. } => {
2190                assert_eq!(vec![101, 102], items);
2191                assert!(dry_run);
2192            }
2193            other => panic!("{other:?}"),
2194        }
2195    }
2196
2197    #[test]
2198    fn review_posts_unless_told_not_to() {
2199        match Cli::parse_from(["spar", "review", "101"]).command {
2200            Command::Review { dry_run, .. } => assert!(!dry_run),
2201            other => panic!("{other:?}"),
2202        }
2203    }
2204
2205    #[test]
2206    fn review_with_no_numbers_is_allowed() {
2207        match Cli::parse_from(["spar", "review"]).command {
2208            Command::Review { items, .. } => assert!(items.is_empty()),
2209            other => panic!("{other:?}"),
2210        }
2211    }
2212
2213    #[test]
2214    fn review_takes_its_own_round_budget() {
2215        match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
2216            Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
2217            other => panic!("{other:?}"),
2218        }
2219    }
2220
2221    #[test]
2222    fn checkin_takes_pr_numbers_and_a_dry_run() {
2223        match Cli::parse_from(["spar", "checkin", "108", "112", "--dry-run"]).command {
2224            Command::Checkin { items, dry_run, .. } => {
2225                assert_eq!(vec![108, 112], items);
2226                assert!(dry_run);
2227            }
2228            other => panic!("{other:?}"),
2229        }
2230        match Cli::parse_from(["spar", "checkin"]).command {
2231            Command::Checkin { items, dry_run, .. } => {
2232                assert!(items.is_empty());
2233                assert!(!dry_run);
2234            }
2235            other => panic!("{other:?}"),
2236        }
2237    }
2238
2239    /// `--auto-merge` must not exist on the one command whose input is written
2240    /// by somebody else, and `--max-rounds` would be worse than useless: the
2241    /// judgement is two passes by construction, so `--max-rounds 1` could only
2242    /// mean "let one agent decide alone", which removes the one thing standing
2243    /// between a stranger's comment and a push.
2244    #[test]
2245    fn checkin_offers_no_flag_that_would_weaken_the_pair() {
2246        assert!(Cli::try_parse_from(["spar", "checkin", "--auto-merge"]).is_err());
2247        assert!(Cli::try_parse_from(["spar", "checkin", "--max-rounds", "1"]).is_err());
2248        assert!(Cli::try_parse_from(["spar", "checkin", "--absorb", "1"]).is_err());
2249        assert!(Cli::try_parse_from(["spar", "checkin", "--close-skipped"]).is_err());
2250        // What it does offer.
2251        assert!(Cli::try_parse_from(["spar", "checkin", "--reply-only"]).is_ok());
2252        assert!(Cli::try_parse_from(["spar", "checkin", "--any-author"]).is_ok());
2253        assert!(Cli::try_parse_from(["spar", "checkin", "--again"]).is_ok());
2254        assert!(Cli::try_parse_from(["spar", "checkin", "--keep-worktrees"]).is_ok());
2255    }
2256
2257    #[test]
2258    fn resume_takes_a_next_override() {
2259        let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
2260        match cli.command {
2261            Command::Resume {
2262                prs, next_actor, ..
2263            } => {
2264                assert_eq!(vec![108], prs);
2265                assert_eq!(Some("codex".to_string()), next_actor);
2266            }
2267            other => panic!("{other:?}"),
2268        }
2269    }
2270}
2271
2272#[cfg(test)]
2273mod absorb_tests {
2274    use super::*;
2275
2276    #[test]
2277    fn absorb_is_off_unless_asked_for() {
2278        match Cli::parse_from(["spar", "run"]).command {
2279            Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
2280            other => panic!("{other:?}"),
2281        }
2282    }
2283
2284    #[test]
2285    fn absorb_takes_a_wave_count() {
2286        match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
2287            Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
2288            other => panic!("{other:?}"),
2289        }
2290    }
2291
2292    #[test]
2293    fn absorb_is_only_offered_where_issues_are_worked() {
2294        assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
2295        assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
2296        assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
2297        // `split` decomposes and stops, so there is nothing for a wave of newly
2298        // filed issues to be absorbed into.
2299        assert!(Cli::try_parse_from(["spar", "split", "--absorb", "1"]).is_err());
2300    }
2301}
2302
2303#[cfg(test)]
2304mod split_tests {
2305    use super::*;
2306
2307    #[test]
2308    fn split_takes_numbers_of_either_kind() {
2309        match Cli::parse_from(["spar", "split", "10", "12"]).command {
2310            Command::Split { items, .. } => assert_eq!(vec![10, 12], items),
2311            other => panic!("{other:?}"),
2312        }
2313    }
2314
2315    /// The bare form means both kinds, which no other command does, so it has
2316    /// to be allowed to take nothing.
2317    #[test]
2318    fn split_with_no_numbers_is_allowed() {
2319        match Cli::parse_from(["spar", "split"]).command {
2320            Command::Split {
2321                items,
2322                dry_run,
2323                again,
2324                ..
2325            } => {
2326                assert!(items.is_empty());
2327                assert!(!dry_run);
2328                assert!(!again);
2329            }
2330            other => panic!("{other:?}"),
2331        }
2332    }
2333
2334    /// A wrong split is N issues to close, a checklist to strip out of
2335    /// somebody's body, and branches and pull requests to delete, so the read
2336    /// only half is not optional.
2337    #[test]
2338    fn split_can_be_told_to_write_nothing() {
2339        match Cli::parse_from(["spar", "split", "10", "--dry-run"]).command {
2340            Command::Split { dry_run, .. } => assert!(dry_run),
2341            other => panic!("{other:?}"),
2342        }
2343    }
2344
2345    /// The flag `checkin` established for exactly this: do it again on
2346    /// something spar already dealt with.
2347    #[test]
2348    fn split_can_be_told_to_split_something_it_already_split() {
2349        match Cli::parse_from(["spar", "split", "10", "--again"]).command {
2350            Command::Split { again, .. } => assert!(again),
2351            other => panic!("{other:?}"),
2352        }
2353    }
2354
2355    /// It picks for itself when given nothing, so it takes both of the flags
2356    /// that govern picking, and its config like everything else.
2357    #[test]
2358    fn split_takes_the_flags_that_govern_picking() {
2359        match Cli::parse_from([
2360            "spar",
2361            "split",
2362            "--limit",
2363            "5",
2364            "--min-number",
2365            "480",
2366            "--config",
2367            "other.toml",
2368        ])
2369        .command
2370        {
2371            Command::Split { common, .. } => {
2372                assert_eq!(5, common.limit);
2373                assert_eq!(Some(480), common.min_number);
2374                assert_eq!(Some(PathBuf::from("other.toml")), common.config);
2375            }
2376            other => panic!("{other:?}"),
2377        }
2378    }
2379
2380    /// `split` decomposes and stops. A flag that implied it works, reviews, or
2381    /// merges anything would be a flag that does nothing.
2382    #[test]
2383    fn split_offers_no_flag_that_would_make_it_a_second_run() {
2384        for flag in [
2385            vec!["--auto-merge"],
2386            vec!["--max-rounds", "2"],
2387            vec!["--close-skipped"],
2388            vec!["--no-close-skipped"],
2389            vec!["--keep-worktrees"],
2390        ] {
2391            let mut argv = vec!["spar", "split", "10"];
2392            argv.extend(flag.iter().copied());
2393            assert!(Cli::try_parse_from(&argv).is_err(), "{argv:?}");
2394        }
2395    }
2396}
2397
2398#[cfg(test)]
2399mod min_number_tests {
2400    use super::*;
2401
2402    fn read(argv: &[&str]) -> Option<i64> {
2403        match Cli::parse_from(argv).command {
2404            Command::Run { common, .. }
2405            | Command::Triage { common, .. }
2406            | Command::Resume { common, .. }
2407            | Command::Review { common, .. }
2408            | Command::Followup { common, .. }
2409            | Command::Split { common, .. }
2410            | Command::Checkin { common, .. } => common.min_number,
2411            other => panic!("{other:?}"),
2412        }
2413    }
2414
2415    #[test]
2416    fn there_is_no_floor_unless_one_is_asked_for() {
2417        assert_eq!(None, read(&["spar", "run"]));
2418    }
2419
2420    #[test]
2421    fn every_command_that_picks_for_itself_accepts_a_floor() {
2422        for cmd in ["run", "triage", "resume", "review", "checkin", "split"] {
2423            assert_eq!(
2424                Some(480),
2425                read(&["spar", cmd, "--min-number", "480"]),
2426                "{cmd}"
2427            );
2428        }
2429    }
2430}
2431
2432#[cfg(test)]
2433mod settings_block_tests {
2434    use super::*;
2435
2436    /// The value a config line offers, with its trailing note removed. Quote
2437    /// aware, since a note is free to contain a `#` and several do.
2438    fn written(line: &str) -> String {
2439        let after = line.split_once('=').expect("an assignment").1;
2440        let mut quoted = false;
2441        for (i, c) in after.char_indices() {
2442            match c {
2443                '"' => quoted = !quoted,
2444                '#' if !quoted => return after[..i].trim().to_string(),
2445                _ => {}
2446            }
2447        }
2448        after.trim().to_string()
2449    }
2450
2451    fn line_for(text: &str, key: &str) -> String {
2452        text.lines()
2453            .find(|l| {
2454                let bare = l.trim_start().trim_start_matches('#').trim_start();
2455                bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
2456            })
2457            .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
2458            .to_string()
2459    }
2460
2461    /// The guard that was missing. Every number in the generated config used to
2462    /// be typed in beside its comment, which is a second copy of a default that
2463    /// lives in the code, and the copies stopped agreeing: it offered a title
2464    /// budget of 90 against a real 140, a body of 900 against 8000, and three
2465    /// more like it. Uncommenting one to see what it did cut every comment spar
2466    /// posts to a fifth of its length.
2467    #[test]
2468    fn every_value_it_offers_is_the_default_it_actually_has() {
2469        let text = settings_block("claude");
2470        for option in config::known_options() {
2471            // The effort words are per CLI, so the schedule's are examples of
2472            // what one accepts rather than defaults. There is no default
2473            // effort: an agent that names none uses its own CLI's.
2474            if option.section == "loop.effort_schedule" {
2475                continue;
2476            }
2477            let line = line_for(&text, &option.key);
2478            assert_eq!(
2479                option.default,
2480                written(&line),
2481                "the generated config offers `{}`, but the default is {}",
2482                line.trim(),
2483                option.default
2484            );
2485        }
2486    }
2487
2488    /// `doctor` reports what a config does not mention, so a generated one
2489    /// should send nobody to that list on the day it was written. pr_comments
2490    /// was missing from it for exactly that long.
2491    #[test]
2492    fn it_offers_every_option_the_parser_knows_about() {
2493        let text = settings_block("claude");
2494        let missing: Vec<String> = config::unmentioned_options(&text)
2495            .into_iter()
2496            .map(|o| format!("[{}] {}", o.section, o.key))
2497            .collect();
2498        assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
2499    }
2500
2501    /// The strongest of these: every line the file suggests has to be a line
2502    /// that works. A commented option is an invitation to uncomment it, and one
2503    /// that then fails to load is worse than never having offered it.
2504    #[test]
2505    fn every_option_it_offers_can_be_uncommented_and_still_load() {
2506        let mut text = String::from(
2507            "[agents.claude]\ncommand = [\"claude\"]\n\n\
2508             [agents.codex]\ncommand = [\"codex\"]\n\n",
2509        );
2510        for line in settings_block("claude").lines() {
2511            text.push_str(uncomment(line).unwrap_or(line));
2512            text.push('\n');
2513        }
2514        let cfg = config::parse(&text).expect("a config of its own suggestions");
2515        assert_eq!("claude", cfg.first_implementor);
2516    }
2517
2518    /// A commented assignment with its `#` removed, or None for a line of
2519    /// prose, which stays a comment.
2520    fn uncomment(line: &str) -> Option<&str> {
2521        let bare = line.trim_start().strip_prefix('#')?.trim_start();
2522        // An assignment, not a wrapped note that happens to contain an `=`:
2523        // the key has to be one bare word.
2524        let key = bare.split_once('=')?.0.trim();
2525        let named = !key.is_empty()
2526            && key
2527                .chars()
2528                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
2529        named.then_some(bare)
2530    }
2531
2532    #[test]
2533    fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
2534        assert!(settings_block("codex").contains("first_implementor = \"codex\""));
2535    }
2536
2537    /// Every line starts at the margin, and an option's note sits above the
2538    /// option rather than beside it. Beside meant three different columns in
2539    /// one file, and a note that wrapped left the reader tracking indentation
2540    /// to work out which setting it belonged to.
2541    #[test]
2542    fn a_note_sits_above_the_option_it_describes() {
2543        let text = settings_block("claude");
2544        assert!(
2545            text.lines().all(|l| l.chars().count() <= 80),
2546            "a line runs off the edge:\n{text}"
2547        );
2548        assert!(
2549            text.lines().all(|l| !l.starts_with(' ')),
2550            "a line is indented, so the columns are back:\n{text}"
2551        );
2552
2553        // The line before an option carries its note, not another option.
2554        let lines: Vec<&str> = text.lines().collect();
2555        let at = lines
2556            .iter()
2557            .position(|l| l.starts_with("max_rounds"))
2558            .expect("max_rounds");
2559        assert!(lines[at - 1].starts_with('#'), "{:?}", lines[at - 1]);
2560        assert!(
2561            lines[at - 1].contains("lifetime cap"),
2562            "the note above it is the end of its own note: {:?}",
2563            lines[at - 1]
2564        );
2565    }
2566
2567    /// A note reads as a sentence now that it leads rather than trails.
2568    #[test]
2569    fn every_note_starts_as_a_sentence() {
2570        for (_, key, note) in LOOP_OPTIONS.iter().chain(STYLE_OPTIONS) {
2571            let first = note.chars().next().expect("a note");
2572            assert!(
2573                first.is_uppercase(),
2574                "{key} reads as a margin scribble rather than a sentence: {note}"
2575            );
2576        }
2577    }
2578}
2579
2580#[cfg(test)]
2581mod agent_block_tests {
2582    use super::*;
2583
2584    fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
2585        let mut spec: config::AgentSpec =
2586            toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
2587                .try_into()
2588                .expect("builds");
2589        spec.models = models.iter().map(|s| s.to_string()).collect();
2590        spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
2591        spec
2592    }
2593
2594    /// A placeholder is not a working value. `# effort = "..."` was written for
2595    /// every preset that lists no efforts, and taking the file at its word by
2596    /// uncommenting it passes `...` to the CLI as a real setting.
2597    #[test]
2598    fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
2599        let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
2600        assert!(!block.contains("..."), "{block}");
2601        assert!(!block.contains("effort"), "{block}");
2602        assert!(block.contains("# model = \"composer-2.5\""), "{block}");
2603    }
2604
2605    /// Each option is introduced by its own line, so a block with no effort
2606    /// setting never mentions one.
2607    #[test]
2608    fn only_the_options_that_follow_are_introduced() {
2609        let model_only = agent_block("cursor", &spec(&["auto"], &[]));
2610        assert!(model_only.contains("Omit model to use"), "{model_only}");
2611        assert!(!model_only.contains("Omit effort"), "{model_only}");
2612
2613        let both = agent_block("claude", &spec(&["fable"], &["high"]));
2614        assert!(both.contains("Omit model to use"), "{both}");
2615        assert!(both.contains("Omit effort to use"), "{both}");
2616    }
2617
2618    /// A preset with no hints at all still has to produce a loadable block,
2619    /// which is every preset that has never listed any: gemini and aider.
2620    #[test]
2621    fn a_preset_with_no_hints_still_writes_a_usable_block() {
2622        let block = agent_block("gemini", &spec(&[], &[]));
2623        assert!(!block.contains("..."), "{block}");
2624        assert!(!block.contains("Omit"), "{block}");
2625        assert!(
2626            block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
2627            "{block}"
2628        );
2629        // What the block does still carry is unaffected.
2630        assert!(block.contains("[agents.gemini.fallback]"), "{block}");
2631        assert!(block.contains("# timeout = "), "{block}");
2632    }
2633
2634    /// The timeout comes from the spec rather than a number typed into the
2635    /// generator, for the reason the [loop] block learned the hard way.
2636    #[test]
2637    fn the_timeout_offered_is_the_one_the_agent_would_use() {
2638        let mut spec = spec(&["a"], &[]);
2639        spec.timeout = 7200;
2640        assert!(
2641            agent_block("custom", &spec).contains("# timeout = 7200"),
2642            "the generator kept its own copy"
2643        );
2644    }
2645
2646    /// Alternatives are named, and a single choice is not dressed up as one.
2647    #[test]
2648    fn alternatives_are_listed_only_when_there_are_any() {
2649        assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("One of: one | two."));
2650        let single = agent_block("b", &spec(&["only"], &[]));
2651        assert!(!single.contains("One of:"), "{single}");
2652    }
2653
2654    /// The preset's own note is about the pair, so it appears once above them
2655    /// rather than under each, which put a sentence about models beneath the
2656    /// effort line.
2657    #[test]
2658    fn the_presets_note_is_said_once() {
2659        let mut spec = spec(&["m1", "m2"], &["e1", "e2"]);
2660        spec.options_note = Some("Check the current sets with: mytool --help".into());
2661        let block = agent_block("mytool", &spec);
2662        assert_eq!(
2663            1,
2664            block.matches("Check the current sets").count(),
2665            "{block}"
2666        );
2667    }
2668}