Skip to main content

spar/
cli.rs

1//! The command line.
2
3use std::collections::BTreeSet;
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6
7use clap::{Args, Parser, Subcommand};
8
9use crate::agent::{self, Agent};
10use crate::config::{self, Config};
11use crate::error::Result;
12use crate::model::{Issue, IssueRun, ItemKind, Ledger, Plan, Status};
13use crate::proc::{self, ExecOpts};
14use crate::repo::Repo;
15use crate::review;
16use crate::review_only;
17use crate::style;
18use crate::triage;
19use crate::{bail, log, logdim, logging, logwarn, spar_err};
20
21pub const VERSION: &str = env!("CARGO_PKG_VERSION");
22
23#[derive(Parser, Debug)]
24#[command(
25    name = "spar",
26    version = VERSION,
27    about = "Two coding agents alternate implementing and reviewing GitHub issues.",
28    long_about = "Two coding agents alternate implementing and reviewing GitHub issues until a \
29                  pull request converges. Neither agent reviews its own most recent edit.\n\n\
30                  Arguments are issue numbers for `run` and `triage`, and pull request numbers \
31                  for `resume`. Omit them and spar takes everything open, up to --limit.",
32    max_term_width = 96
33)]
34pub struct Cli {
35    /// Suppress progress logging. Warnings, errors, and the final summary still print.
36    #[arg(short, long, global = true)]
37    pub quiet: bool,
38
39    #[command(subcommand)]
40    pub command: Command,
41}
42
43#[derive(Subcommand, Debug)]
44pub enum Command {
45    /// Triage the issues, then work them in dependency order.
46    Run {
47        /// Issue numbers. Omit to take every open issue, up to --limit.
48        issues: Vec<i64>,
49        #[command(flatten)]
50        common: Common,
51        #[command(flatten)]
52        loop_flags: LoopFlags,
53        #[command(flatten)]
54        triage_flags: TriageFlags,
55        /// Where to write the triage plan.
56        #[arg(long, default_value = "plan.json")]
57        plan_out: PathBuf,
58        /// Work in the main checkout instead of an isolated worktree per issue.
59        #[arg(long)]
60        no_worktrees: bool,
61    },
62
63    /// Triage only. Writes the plan and touches nothing else.
64    Triage {
65        /// Issue numbers. Omit to take every open issue, up to --limit.
66        issues: Vec<i64>,
67        #[command(flatten)]
68        common: Common,
69        #[arg(long, default_value = "plan.json")]
70        plan_out: PathBuf,
71    },
72
73    /// Continue the review loop on existing PRs, including ones spar did not create.
74    Resume {
75        /// Pull request numbers. Omit to take every open PR, up to --limit.
76        prs: Vec<i64>,
77        #[command(flatten)]
78        common: Common,
79        #[command(flatten)]
80        loop_flags: LoopFlags,
81        /// Which agent reviews next, overriding the PR's saved state.
82        #[arg(long = "next", value_name = "AGENT")]
83        next_actor: Option<String>,
84    },
85
86    /// Review pull requests without changing them, including from a fork.
87    ///
88    /// Both agents review independently, then rule on each other's findings,
89    /// then answer the objections. Nothing is committed, pushed, or merged.
90    Review {
91        /// Pull request numbers. An issue number resolves to its open PR.
92        /// Omit to take every open PR, up to --limit.
93        items: Vec<i64>,
94        #[command(flatten)]
95        common: Common,
96        /// Print the review instead of posting it.
97        #[arg(long)]
98        dry_run: bool,
99        /// Adjudication passes. 1 is two independent reviews with no
100        /// cross-checking, 2 adds it, 3 adds a rebuttal on what they dispute.
101        #[arg(long)]
102        max_rounds: Option<u32>,
103    },
104
105    /// Post a review a dry run produced, without running the agents again.
106    ///
107    /// `spar review <pr> --dry-run` saves what it produced. Read it, edit the
108    /// file if you like, then post exactly that.
109    Post {
110        /// Pull request numbers whose saved review should be posted.
111        #[arg(required = true)]
112        prs: Vec<i64>,
113        #[arg(long, default_value = ".")]
114        repo: PathBuf,
115        #[arg(long)]
116        config: Option<PathBuf>,
117        /// Post this file instead of the saved review.
118        #[arg(long, value_name = "PATH")]
119        file: Option<PathBuf>,
120        /// Print what would be posted and stop.
121        #[arg(long)]
122        dry_run: bool,
123    },
124
125    /// Detect installed agent CLIs and write a spar.toml.
126    ///
127    /// On an existing config, `--update` appends any settings it does not
128    /// mention, which is how to pick up options added by a newer release.
129    Init {
130        #[arg(long, default_value = "spar.toml")]
131        out: PathBuf,
132        /// Overwrite an existing config.
133        #[arg(long)]
134        force: bool,
135        /// Append settings the existing config does not mention, as comments.
136        /// Nothing already in the file is changed.
137        #[arg(long, conflicts_with = "force")]
138        update: bool,
139    },
140
141    /// Remove worktrees, branches, and state whose PR is merged or closed.
142    Clean {
143        #[arg(long, default_value = ".")]
144        repo: PathBuf,
145        #[arg(long)]
146        config: Option<PathBuf>,
147        /// Remove every worktree and branch spar created, even for open PRs.
148        #[arg(long)]
149        all: bool,
150        /// Also delete state comments left on finished PRs.
151        #[arg(long)]
152        pr_state: bool,
153    },
154
155    /// Check prerequisites and resolve each configured agent.
156    Doctor {
157        #[arg(long)]
158        config: Option<PathBuf>,
159    },
160
161    /// Read a commit message on stdin and write the scrubbed version to stdout.
162    ///
163    /// Used by `git filter-branch`, not by people.
164    #[command(hide = true)]
165    ScrubFilter,
166}
167
168#[derive(Args, Debug, Clone)]
169pub struct Common {
170    /// Path to the git repository.
171    #[arg(long, default_value = ".")]
172    pub repo: PathBuf,
173    /// Path to spar.toml.
174    #[arg(long)]
175    pub config: Option<PathBuf>,
176    /// Base branch. Defaults to whatever origin/HEAD points at.
177    #[arg(long)]
178    pub base: Option<String>,
179    /// Which agent implements first. A key from the [agents] table.
180    #[arg(long)]
181    pub first: Option<String>,
182    /// Cap on how many open items to take when none are named.
183    #[arg(long, default_value_t = 20)]
184    pub limit: usize,
185    /// Ignore issues and pull requests numbered below this when picking for
186    /// itself. A number you name explicitly is always honoured.
187    #[arg(long, value_name = "N")]
188    pub min_number: Option<i64>,
189}
190
191#[derive(Args, Debug, Clone)]
192pub struct LoopFlags {
193    /// Review rounds this run may spend before escalating. Resuming grants a
194    /// fresh budget; it is not a lifetime cap on the pull request.
195    #[arg(long)]
196    pub max_rounds: Option<u32>,
197    /// Merge when no blocking findings remain. Off by default, deliberately.
198    #[arg(long)]
199    pub auto_merge: bool,
200    /// Leave worktrees in place after a run, for inspection.
201    #[arg(long)]
202    pub keep_worktrees: bool,
203    /// Waves of newly filed follow-ups to fold back into this run instead of
204    /// leaving them for the next one. Each wave is triaged like any issue.
205    #[arg(long, value_name = "N")]
206    pub absorb: Option<u32>,
207}
208
209/// Only `run` triages, so only `run` can decline an issue. Offering these on
210/// `resume` would accept a flag that does nothing.
211#[derive(Args, Debug, Clone)]
212pub struct TriageFlags {
213    /// Close an issue both agents declined, after posting the reasoning.
214    #[arg(long, conflicts_with = "no_close_skipped")]
215    pub close_skipped: bool,
216    /// Comment on a declined issue but leave it open.
217    #[arg(long)]
218    pub no_close_skipped: bool,
219}
220
221// ---------------------------------------------------------------------------
222// Entry
223// ---------------------------------------------------------------------------
224
225pub fn main() -> i32 {
226    let cli = Cli::parse();
227    logging::init_color();
228    logging::set_quiet(cli.quiet);
229
230    match dispatch(cli) {
231        Ok(code) => code,
232        Err(e) => {
233            logging::error(e.to_string());
234            2
235        }
236    }
237}
238
239fn dispatch(cli: Cli) -> Result<i32> {
240    match cli.command {
241        Command::ScrubFilter => cmd_scrub_filter(),
242        Command::Doctor { config } => cmd_doctor(config.as_deref()),
243        Command::Review {
244            items,
245            common,
246            dry_run,
247            max_rounds,
248        } => {
249            let overrides = Overrides {
250                max_rounds,
251                ..Overrides::default()
252            };
253            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
254            let numbers = if items.is_empty() {
255                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
256                if found.is_empty() {
257                    log!("no open PRs");
258                    return Ok(0);
259                }
260                log!("no PRs given, reviewing {} open", found.len());
261                found
262            } else {
263                items
264            };
265            let sorted = classify(&repo, &numbers)?;
266            let mut targets = sorted.prs;
267            for number in sorted.issues {
268                match repo.open_pr_for_issue(number) {
269                    Some(pr) => {
270                        log!("#{number} is an issue; reviewing its open PR {}", pr.url);
271                        targets.push(pr.number);
272                    }
273                    None => logwarn!("#{number} is an issue with no open pull request to review"),
274                }
275            }
276            let mut results = Vec::new();
277            for number in targets {
278                results.push(review_only::review_pr(
279                    &agents, &cfg, &repo, number, dry_run,
280                ));
281            }
282            if results.is_empty() {
283                return Ok(0);
284            }
285            Ok(report(&results, &cfg))
286        }
287
288        Command::Post {
289            prs,
290            repo: repo_path,
291            config,
292            file,
293            dry_run,
294        } => cmd_post(
295            &prs,
296            &repo_path,
297            config.as_deref(),
298            file.as_deref(),
299            dry_run,
300        ),
301
302        Command::Init { out, force, update } => {
303            if update {
304                cmd_init_update(&out)
305            } else {
306                cmd_init(&out, force)
307            }
308        }
309        Command::Clean {
310            repo,
311            config,
312            all,
313            pr_state,
314        } => cmd_clean(&repo, config.as_deref(), all, pr_state),
315        Command::Triage {
316            issues,
317            common,
318            plan_out,
319        } => {
320            let (cfg, repo, agents) = prepare(&common, None)?;
321            let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
322            if numbers.is_empty() {
323                return Ok(0);
324            }
325            let sorted = classify(&repo, &numbers)?;
326            for number in &sorted.prs {
327                log!("#{number} is a pull request, nothing to triage");
328            }
329            if sorted.issues.is_empty() {
330                log!("no issues to triage");
331                return Ok(0);
332            }
333            let issues = repo.fetch_issues(&sorted.issues)?;
334            // Deliberately no act_on_plan here. `triage` is the command you
335            // reach for to look before leaping, and a preview that comments on
336            // and closes issues is a trap.
337            make_plan(&agents, &cfg, &repo, &issues, &plan_out)?;
338            Ok(0)
339        }
340        Command::Run {
341            issues,
342            common,
343            loop_flags,
344            triage_flags,
345            plan_out,
346            no_worktrees,
347        } => {
348            let mut overrides = Overrides::from(&loop_flags);
349            overrides.worktrees = if no_worktrees { Some(false) } else { None };
350            overrides.close_skipped =
351                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
352                    (true, _) => Some(true),
353                    (_, true) => Some(false),
354                    _ => None,
355                };
356            let (cfg, repo, agents) = prepare(&common, Some(overrides))?;
357            let numbers = pick_issues(&repo, issues, common.limit, cfg.loop_cfg.min_number)?;
358            if numbers.is_empty() {
359                return Ok(0);
360            }
361            let sorted = classify(&repo, &numbers)?;
362            let mut results = Vec::new();
363            let mut ledger = Ledger::new();
364            let mut handled: BTreeSet<i64> = BTreeSet::new();
365            let mut wave = sorted.issues.clone();
366
367            // Wave 0 is what was asked for. Each further wave is the follow-ups
368            // the previous one filed, folded back in rather than left for the
369            // next run. Every wave is triaged like anything else, so both
370            // agents still have to agree each one is worth doing.
371            for round in 0..=cfg.loop_cfg.absorb_new_issues {
372                wave.retain(|n| !handled.contains(n));
373                if wave.is_empty() {
374                    break;
375                }
376                if round > 0 {
377                    log!(
378                        "absorbing {} newly filed issue(s): {}",
379                        wave.len(),
380                        wave.iter()
381                            .map(|n| format!("#{n}"))
382                            .collect::<Vec<_>>()
383                            .join(", ")
384                    );
385                }
386                handled.extend(wave.iter().copied());
387
388                let fetched = match repo.fetch_issues(&wave) {
389                    Ok(fetched) => fetched,
390                    Err(e) => {
391                        logdim!("could not read the next wave: {e}");
392                        break;
393                    }
394                };
395                let plan_path = if round == 0 {
396                    plan_out.clone()
397                } else {
398                    plan_out.with_extension(format!("wave{round}.json"))
399                };
400                let plan = make_plan(&agents, &cfg, &repo, &fetched, &plan_path)?;
401                act_on_plan(&cfg, &repo, &plan);
402
403                let before = results.len();
404                for item in &plan.order {
405                    let Some(issue) = fetched.iter().find(|i| i.number == item.issue) else {
406                        continue;
407                    };
408                    results.push(review::run_issue(
409                        &agents,
410                        &cfg,
411                        &repo,
412                        item,
413                        issue,
414                        &mut ledger,
415                    ));
416                }
417
418                // Whatever this wave filed becomes the next one.
419                wave = results[before..]
420                    .iter()
421                    .flat_map(|r| r.filed.iter())
422                    .filter_map(|url| review::filed_issue_number(url))
423                    .collect::<BTreeSet<_>>()
424                    .into_iter()
425                    .collect();
426            }
427            if !wave.is_empty() && cfg.loop_cfg.absorb_new_issues > 0 {
428                log!(
429                    "{} issue(s) filed in the last wave were left for a later run: {}",
430                    wave.len(),
431                    wave.iter()
432                        .map(|n| format!("#{n}"))
433                        .collect::<Vec<_>>()
434                        .join(", ")
435                );
436            }
437
438            for number in sorted.prs {
439                results.push(review::resume_pr(&agents, &cfg, &repo, number, None));
440            }
441
442            if results.is_empty() {
443                log!("nothing scheduled");
444                return Ok(0);
445            }
446            Ok(report(&results, &cfg))
447        }
448        Command::Resume {
449            prs,
450            common,
451            loop_flags,
452            next_actor,
453        } => {
454            let (cfg, repo, agents) = prepare(&common, Some(Overrides::from(&loop_flags)))?;
455            if let Some(name) = &next_actor {
456                if !cfg.has_agent(name) {
457                    bail!("--next must be one of: {}", cfg.agent_names().join(", "));
458                }
459            }
460            let numbers = if prs.is_empty() {
461                let found = repo.list_open_prs(common.limit, cfg.loop_cfg.min_number)?;
462                if found.is_empty() {
463                    log!("no open PRs");
464                    return Ok(0);
465                }
466                log!(
467                    "no PRs given, taking {} open: {}",
468                    found.len(),
469                    found
470                        .iter()
471                        .map(|n| format!("#{n}"))
472                        .collect::<Vec<_>>()
473                        .join(", ")
474                );
475                found
476            } else {
477                prs
478            };
479            let sorted = classify(&repo, &numbers)?;
480            let mut results = Vec::new();
481            for number in sorted.prs {
482                results.push(review::resume_pr(
483                    &agents,
484                    &cfg,
485                    &repo,
486                    number,
487                    next_actor.as_deref(),
488                ));
489            }
490            // An issue number handed to `resume` is not a mistake worth
491            // refusing over. If work is already open for it, continue that.
492            for number in sorted.issues {
493                match repo.open_pr_for_issue(number) {
494                    Some(pr) => {
495                        log!("#{number} is an issue; continuing its open PR {}", pr.url);
496                        results.push(review::resume_pr(
497                            &agents,
498                            &cfg,
499                            &repo,
500                            pr.number,
501                            next_actor.as_deref(),
502                        ));
503                    }
504                    None => logwarn!(
505                        "#{number} is an issue with no open pull request. Use `spar run {number}` \
506                         to implement it."
507                    ),
508                }
509            }
510            if results.is_empty() {
511                return Ok(0);
512            }
513            Ok(report(&results, &cfg))
514        }
515    }
516}
517
518// ---------------------------------------------------------------------------
519// Shared setup
520// ---------------------------------------------------------------------------
521
522#[derive(Debug, Default, Clone)]
523struct Overrides {
524    max_rounds: Option<u32>,
525    auto_merge: Option<bool>,
526    keep_worktrees: Option<bool>,
527    worktrees: Option<bool>,
528    close_skipped: Option<bool>,
529    absorb: Option<u32>,
530}
531
532impl From<&LoopFlags> for Overrides {
533    fn from(flags: &LoopFlags) -> Self {
534        Self {
535            max_rounds: flags.max_rounds,
536            auto_merge: flags.auto_merge.then_some(true),
537            keep_worktrees: flags.keep_worktrees.then_some(true),
538            worktrees: None,
539            close_skipped: None,
540            absorb: flags.absorb,
541        }
542    }
543}
544
545fn prepare(common: &Common, overrides: Option<Overrides>) -> Result<(Config, Repo, Vec<Agent>)> {
546    let mut cfg = config::load(common.config.as_deref())?;
547
548    if let Some(first) = &common.first {
549        if !cfg.has_agent(first) {
550            bail!("--first must be one of: {}", cfg.agent_names().join(", "));
551        }
552        cfg.first_implementor = first.clone();
553    }
554    if let Some(base) = &common.base {
555        cfg.loop_cfg.base_branch = base.clone();
556    }
557    if let Some(min) = common.min_number {
558        cfg.loop_cfg.min_number = min;
559    }
560    if let Some(over) = overrides {
561        if let Some(v) = over.max_rounds {
562            if v == 0 {
563                bail!("--max-rounds must be at least 1");
564            }
565            cfg.loop_cfg.max_rounds = v;
566        }
567        if let Some(v) = over.auto_merge {
568            cfg.loop_cfg.auto_merge = v;
569        }
570        if let Some(v) = over.keep_worktrees {
571            cfg.loop_cfg.keep_worktrees = v;
572        }
573        if let Some(v) = over.worktrees {
574            cfg.loop_cfg.worktrees = v;
575        }
576        if let Some(v) = over.close_skipped {
577            cfg.loop_cfg.close_skipped = v;
578        }
579        if let Some(v) = over.absorb {
580            cfg.loop_cfg.absorb_new_issues = v;
581        }
582    }
583
584    let repo = Repo::open(&common.repo, &cfg)?;
585    if common.base.is_none() {
586        cfg.loop_cfg.base_branch = repo.default_branch(cfg.base_branch());
587    }
588
589    let agents = agent::build(&cfg)?;
590    if let Some(warning) = agent::correlation_warning(&agents) {
591        logging::warn(warning);
592    }
593
594    // Sweep finished worktrees before starting, so they cannot accumulate.
595    for stale in repo.prune_worktrees(false) {
596        let what = if stale.starts_with("branch ") {
597            stale
598        } else {
599            format!("worktree {stale}")
600        };
601        logdim!("cleaned up finished {what}");
602    }
603
604    log!("repo {} base {}", repo.root().display(), cfg.base_branch());
605    log!(
606        "agents: {}",
607        agents
608            .iter()
609            .map(|a| format!("{}={}", a.name(), a.spec.describe()))
610            .collect::<Vec<_>>()
611            .join(", ")
612    );
613    Ok((cfg, repo, agents))
614}
615
616fn pick_issues(repo: &Repo, given: Vec<i64>, limit: usize, min_number: i64) -> Result<Vec<i64>> {
617    if !given.is_empty() {
618        // Naming a number is the point, so a floor never overrides it.
619        if min_number > 0 {
620            let below: Vec<String> = given
621                .iter()
622                .filter(|n| **n < min_number)
623                .map(|n| format!("#{n}"))
624                .collect();
625            if !below.is_empty() {
626                logdim!(
627                    "{} below the #{min_number} floor, taking them because you named them",
628                    below.join(", ")
629                );
630            }
631        }
632        return Ok(given);
633    }
634    let found = repo.list_open_issues(limit, min_number)?;
635    if found.is_empty() {
636        log!("no open issues");
637        return Ok(found);
638    }
639    log!(
640        "no issues given, taking {} open: {}",
641        found.len(),
642        found
643            .iter()
644            .map(|n| format!("#{n}"))
645            .collect::<Vec<_>>()
646            .join(", ")
647    );
648    Ok(found)
649}
650
651/// Numbers split by what they actually name.
652///
653/// Issues and pull requests share one number sequence per repository, so a
654/// person should not have to remember which command takes which. Both `run` and
655/// `resume` sort the numbers themselves and route each one.
656#[derive(Debug, Default)]
657struct Sorted {
658    issues: Vec<i64>,
659    prs: Vec<i64>,
660}
661
662fn classify(repo: &Repo, numbers: &[i64]) -> Result<Sorted> {
663    let mut sorted = Sorted::default();
664    for number in numbers {
665        match repo.item_kind(*number)? {
666            ItemKind::Issue => sorted.issues.push(*number),
667            ItemKind::Pr => sorted.prs.push(*number),
668        }
669    }
670    if !sorted.issues.is_empty() && !sorted.prs.is_empty() {
671        log!(
672            "{} issue(s) and {} pull request(s) given",
673            sorted.issues.len(),
674            sorted.prs.len()
675        );
676    }
677    Ok(sorted)
678}
679
680fn make_plan(
681    agents: &[Agent],
682    cfg: &Config,
683    repo: &Repo,
684    issues: &[Issue],
685    plan_out: &Path,
686) -> Result<Plan> {
687    let plan = triage::triage(agents, cfg, repo, issues)?;
688
689    std::fs::write(plan_out, serde_json::to_vec_pretty(&plan)?)
690        .map_err(|e| spar_err!("could not write {}: {e}", plan_out.display()))?;
691    log!("plan written to {}", plan_out.display());
692
693    for item in &plan.order {
694        log!(
695            "  do   #{} [{}/{}] {}",
696            item.issue,
697            item.complexity,
698            item.risk,
699            item.title
700        );
701    }
702    for item in &plan.skipped {
703        log!("  skip #{} (both reviewers: not worth doing)", item.issue);
704    }
705    for item in &plan.contested {
706        log!("  ??   #{} contested, parked for you to decide", item.issue);
707    }
708    Ok(plan)
709}
710
711/// Post the shared reasoning on every issue both agents declined, and close it
712/// when the config says so. Contested issues are never touched.
713fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
714    for item in &plan.skipped {
715        let body = review::skip_comment(item, &repo.style);
716        let outcome = if cfg.loop_cfg.close_skipped {
717            repo.close_issue(item.issue, &body)
718        } else {
719            repo.comment_issue(item.issue, &body)
720        };
721        match outcome {
722            Ok(()) if cfg.loop_cfg.close_skipped => log!("  closed #{}", item.issue),
723            Ok(()) => {}
724            Err(e) => logdim!("could not update #{}: {e}", item.issue),
725        }
726    }
727}
728
729// ---------------------------------------------------------------------------
730// Subcommands
731// ---------------------------------------------------------------------------
732
733fn cmd_scrub_filter() -> Result<i32> {
734    let mut input = String::new();
735    std::io::stdin()
736        .read_to_string(&mut input)
737        .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
738    let out = style::scrub(&input, &crate::repo::style_from_env());
739    let mut stdout = std::io::stdout();
740    stdout
741        .write_all(out.as_bytes())
742        .and_then(|_| stdout.write_all(b"\n"))
743        .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
744    Ok(0)
745}
746
747fn cmd_clean(
748    repo_path: &Path,
749    config_path: Option<&Path>,
750    all: bool,
751    pr_state: bool,
752) -> Result<i32> {
753    let cfg = config::load(config_path)?;
754    let repo = Repo::open(repo_path, &cfg)?;
755    let mut removed = repo.prune_worktrees(all);
756    removed.extend(repo.prune_state());
757    if pr_state {
758        removed.extend(repo.prune_pr_state(None));
759    }
760    if removed.is_empty() {
761        println!("nothing to clean");
762    } else {
763        for item in removed {
764            println!("removed {item}");
765        }
766    }
767    Ok(0)
768}
769
770/// Post a review that was produced earlier and not sent.
771fn cmd_post(
772    prs: &[i64],
773    repo_path: &Path,
774    config_path: Option<&Path>,
775    file: Option<&Path>,
776    dry_run: bool,
777) -> Result<i32> {
778    let cfg = config::load(config_path)?;
779    let repo = Repo::open(repo_path, &cfg)?;
780
781    if file.is_some() && prs.len() > 1 {
782        bail!("--file posts one review, so give it one pull request number");
783    }
784
785    let mut failed = false;
786    for number in prs {
787        let text = match file {
788            Some(path) => std::fs::read_to_string(path)
789                .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
790            None => match repo.read_pending_comment(*number) {
791                Some(text) => text,
792                None => {
793                    logging::error(format!(
794                        "no saved review for PR #{number}. `spar review {number} --dry-run` \
795                         produces one, or pass --file."
796                    ));
797                    failed = true;
798                    continue;
799                }
800            },
801        };
802        if text.trim().is_empty() {
803            logging::error(format!("the saved review for PR #{number} is empty"));
804            failed = true;
805            continue;
806        }
807        if dry_run {
808            println!("\n{}\n", text.trim());
809            log!("would post the above to PR #{number}");
810            continue;
811        }
812        // Through the style gate like anything else spar sends, so an edit that
813        // reintroduces a banned dash is caught rather than published.
814        match repo.comment_pr(*number, &text) {
815            Ok(()) => log!("posted to PR #{number}"),
816            Err(e) => {
817                logging::error(format!("could not post to PR #{number}: {e}"));
818                failed = true;
819            }
820        }
821    }
822    Ok(if failed { 1 } else { 0 })
823}
824
825/// Append the settings a config does not mention, commented out.
826///
827/// Append only by design. Rewriting somebody's config to insert options would
828/// take their comments and their ordering with it, and `--force` already exists
829/// for anyone who wants the generated file back.
830fn cmd_init_update(out: &Path) -> Result<i32> {
831    let text = std::fs::read_to_string(out)
832        .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
833    // Refuse to append to something that does not parse, rather than making a
834    // broken config longer.
835    config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
836
837    let unset = config::unmentioned_options(&text);
838    if unset.is_empty() {
839        println!("{} already mentions every setting.", out.display());
840        return Ok(0);
841    }
842
843    let mut block = String::new();
844    if !text.ends_with('\n') {
845        block.push('\n');
846    }
847    block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
848    block.push_str("# shown at their defaults. Uncomment one to change it.\n");
849    let mut section = "";
850    for option in &unset {
851        if option.section != section {
852            section = option.section;
853            block.push_str(&format!("# [{section}]\n"));
854        }
855        block.push_str(&format!("# {} = {}\n", option.key, option.default));
856    }
857
858    use std::io::Write;
859    std::fs::OpenOptions::new()
860        .append(true)
861        .open(out)
862        .and_then(|mut f| f.write_all(block.as_bytes()))
863        .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
864
865    println!(
866        "added {} setting(s) to {} as comments",
867        unset.len(),
868        out.display()
869    );
870    Ok(0)
871}
872
873fn cmd_init(out: &Path, force: bool) -> Result<i32> {
874    if out.exists() && !force {
875        logging::error(format!(
876            "{} already exists. `--update` appends any settings it does not mention, \
877             `--force` overwrites it.",
878            out.display()
879        ));
880        return Ok(1);
881    }
882
883    let presets = config::available_presets();
884    if presets.is_empty() {
885        bail!("no presets available, which should be impossible in a released build");
886    }
887
888    let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
889    for name in &presets {
890        let raw = config::load_preset(name)?;
891        // A preset that will not build is a broken preset, not an uninstalled
892        // CLI. Skipping it silently reported it as "missing" and sent people
893        // looking for an install problem that was not there.
894        let mut spec: config::AgentSpec = match raw
895            .as_table()
896            .cloned()
897            .ok_or_else(|| spar_err!("not a table"))
898            .and_then(|t| {
899                toml::Value::Table(t)
900                    .try_into()
901                    .map_err(|e| spar_err!("{e}"))
902            }) {
903            Ok(spec) => spec,
904            Err(e) => {
905                println!("  BROKEN   {name:10} {}", e.first_line());
906                continue;
907            }
908        };
909        spec.name = name.clone();
910        match Agent::new(spec.clone()).resolve_bin() {
911            Ok(path) => {
912                println!("  found    {name:10} {}", path.display());
913                found.push((name.clone(), path.to_path_buf(), spec));
914            }
915            Err(_) => println!("  missing  {name}"),
916        }
917    }
918
919    if found.len() < 2 {
920        logging::error(format!(
921            "need two agent CLIs, found {}. Install another, or write {} by hand using the \
922             presets as a reference.",
923            found.len(),
924            out.display()
925        ));
926        return Ok(1);
927    }
928
929    // Prefer a pair that cannot share blind spots, if one is available.
930    let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
931    if found.len() > 2 {
932        log!(
933            "{} agents available, picking {} and {}. Edit {} to change.",
934            found.len(),
935            chosen[0].0,
936            chosen[1].0,
937            out.display()
938        );
939    }
940
941    let mut text = String::from(
942        "# Generated by `spar init`. Each agent inherits a command template from a\n\
943         # built in preset; anything set here overrides it.\n\
944         #\n\
945         # Commented lines are the other options, each with a working value.\n\
946         # Uncomment one to change it.\n\n",
947    );
948    for (name, _, spec) in &chosen {
949        text.push_str(&agent_block(name, spec));
950    }
951    text.push_str(&format!(
952        "[loop]\n\
953         max_rounds        = 3          # review rounds ONE invocation may spend.\n\
954         #                                Resuming grants a fresh budget, so this\n\
955         #                                is not a lifetime cap on a PR.\n\
956         auto_merge        = false      # off on purpose: two models agreeing is\n\
957         #                                not the same as being right\n\
958         first_implementor = \"{}\"\n\
959         worktrees         = true       # false works in the main checkout\n\
960         close_skipped     = true       # close an issue both reviewers declined\n\
961         followups         = \"local\"    # issues | local | none. local writes\n\
962         #                                .spar/followups.md, not the tracker\n\
963         # file_non_blocking = false    # a suggestion is not a tracker item\n\
964         # max_followups     = 5        # backstop on what one run can spawn\n\
965         # keep_worktrees  = false      # true leaves them behind to inspect\n\
966         # min_number      = 0          # ignore anything numbered below this when\n\
967         #                                picking for itself. 0 is no floor.\n\
968         # parallel_triage = true       # false asks the agents one at a time\n\
969         # absorb_new_issues = 0        # waves of newly filed follow-ups to fold\n\
970         #                                back into this run. Costs more.\n\
971         # file_nits       = false      # true files nits as issues too\n\
972         # base_branch     = \"main\"     # only a fallback; origin/HEAD wins\n\
973         # branch_prefix   = \"\"         # e.g. \"spar/\" to namespace branches\n\
974         # state_store     = \"local\"    # local | pr | both\n\n\
975         [loop.effort_schedule]\n\
976         # Values are whatever each agent's own CLI accepts, listed above.\n\
977         # round_1 = \"high\"   # the deep first review\n\
978         # rest    = \"low\"    # later rounds only see a small delta\n\n\
979         [style]\n\
980         ban_em_dash        = true\n\
981         ban_ai_attribution = true\n\
982         terse              = true    # hold model prose to a length budget\n\
983         # max_title_chars   = 90     # a finding, issue, or PR title\n\
984         # max_summary_chars = 200    # a one line verdict or refutation\n\
985         # max_detail_chars  = 320    # a blocking finding, in the PR thread\n\
986         # max_body_chars    = 900    # a PR body\n\
987         # max_issue_body_chars = 4000 # a filed issue's body. Code blocks in\n\
988         #                               it are never truncated.\n",
989        chosen[0].0
990    ));
991
992    std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
993    println!("\nwrote {}", out.display());
994    println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
995    Ok(0)
996}
997
998/// One prerequisite check: a label and something that either reports a version
999/// or explains what is missing.
1000type Probe = Box<dyn Fn() -> Result<String>>;
1001
1002/// One agent's block, with the options commented out beside a working value.
1003///
1004/// The values come from the preset rather than from here, so a CLI that adds a
1005/// model is a file edit. They are hints only: nothing validates against them,
1006/// because a stale list that refused a model which actually works would be
1007/// worse than no hint at all.
1008fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1009    let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1010    out.push_str("# Omit model or effort to use the CLI's own default.\n");
1011
1012    // The first entry of each list is the one written as the suggested value,
1013    // which is why the presets put the sensible default there rather than in
1014    // whatever order a CLI's help happens to print.
1015    fn suggested(choices: &[String]) -> &str {
1016        choices.first().map(String::as_str).unwrap_or("...")
1017    }
1018    let assignments = [
1019        format!("# model  = \"{}\"", suggested(&spec.models)),
1020        format!("# effort = \"{}\"", suggested(&spec.efforts)),
1021    ];
1022    // Line the comments up, so the file reads as a column rather than a jumble.
1023    let column = assignments
1024        .iter()
1025        .map(|a| a.chars().count())
1026        .max()
1027        .unwrap_or(0)
1028        + 3;
1029    for (assignment, choices) in assignments.iter().zip([&spec.models, &spec.efforts]) {
1030        out.push_str(assignment);
1031        if choices.len() > 1 {
1032            let pad = column.saturating_sub(assignment.chars().count());
1033            out.push_str(&" ".repeat(pad));
1034            out.push_str(&format!("# {}", choices.join(" | ")));
1035        }
1036        out.push('\n');
1037    }
1038
1039    if let Some(note) = &spec.options_note {
1040        out.push_str(&wrap_comment(note));
1041    }
1042    out.push('\n');
1043    out
1044}
1045
1046/// Wrap a note across comment lines so a long one does not run off the edge.
1047fn wrap_comment(text: &str) -> String {
1048    const WIDTH: usize = 76;
1049    let mut out = String::new();
1050    let mut line = String::from("#");
1051    for word in text.split_whitespace() {
1052        if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1053            out.push_str(&line);
1054            out.push('\n');
1055            line = String::from("#");
1056        }
1057        line.push(' ');
1058        line.push_str(word);
1059    }
1060    if line.len() > 1 {
1061        out.push_str(&line);
1062        out.push('\n');
1063    }
1064    out
1065}
1066
1067fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1068    let mut ok = true;
1069
1070    let probes: Vec<(&str, Probe)> = vec![
1071        (
1072            "git",
1073            Box::new(|| {
1074                proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1075                    .map(|s| first_line(&s))
1076            }),
1077        ),
1078        (
1079            "gh",
1080            Box::new(|| {
1081                proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1082                    .map(|s| first_line(&s))
1083            }),
1084        ),
1085        (
1086            "gh auth",
1087            Box::new(|| {
1088                let out = proc::exec(
1089                    &["gh".into(), "auth".into(), "status".into()],
1090                    &ExecOpts::new().check(false).timeout_secs(60),
1091                )?;
1092                let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1093                if out.ok() {
1094                    Ok(first_line(&text))
1095                } else {
1096                    Err(spar_err!("not authenticated. Run `gh auth login`."))
1097                }
1098            }),
1099        ),
1100    ];
1101
1102    for (label, probe) in probes {
1103        match probe() {
1104            Ok(detail) => println!("  ok    {label:12} {detail}"),
1105            Err(e) => {
1106                println!("  FAIL  {label:12} {}", e.first_line());
1107                ok = false;
1108            }
1109        }
1110    }
1111
1112    let found = config::find_config(config_path)?;
1113    let Some(path) = found else {
1114        println!("\n  no spar.toml found. Run `spar init` to generate one.");
1115        println!(
1116            "  presets available: {}",
1117            config::available_presets().join(", ")
1118        );
1119        return Ok(if ok { 0 } else { 1 });
1120    };
1121
1122    println!("\n  config: {}", path.display());
1123    let cfg = match config::load(Some(&path)) {
1124        Ok(cfg) => cfg,
1125        Err(e) => {
1126            println!("  FAIL  config       {e}");
1127            return Ok(1);
1128        }
1129    };
1130
1131    // Kept apart from `ok`: a missing gh says nothing about whether the two
1132    // agents are the same CLI, and must not silence the warning below.
1133    let mut resolved = Vec::new();
1134    for spec in &cfg.agents {
1135        let agent = Agent::new(spec.clone());
1136        match agent.resolve_bin() {
1137            Ok(bin) => {
1138                println!(
1139                    "  ok    {:12} {}  ({})",
1140                    spec.name,
1141                    bin.display(),
1142                    spec.describe()
1143                );
1144                resolved.push(agent);
1145            }
1146            Err(e) => {
1147                println!("  FAIL  {:12} {}", spec.name, e.first_line());
1148                ok = false;
1149            }
1150        }
1151    }
1152
1153    if resolved.len() == cfg.agents.len() {
1154        if let Some(warning) = agent::correlation_warning(&resolved) {
1155            println!("\n  WARNING  {warning}");
1156        }
1157    }
1158
1159    println!(
1160        "\n  settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1161        cfg.loop_cfg.max_rounds,
1162        cfg.loop_cfg.auto_merge,
1163        cfg.loop_cfg.worktrees,
1164        cfg.loop_cfg.followups,
1165        cfg.style.terse
1166    );
1167    // What somebody upgrading wants to know. `spar init` refuses to touch an
1168    // existing config, so without this there is no way to learn that a release
1169    // added a setting short of reading the source.
1170    if let Ok(text) = std::fs::read_to_string(&path) {
1171        let unset = config::unmentioned_options(&text);
1172        if !unset.is_empty() {
1173            println!(
1174                "\n  {} setting(s) this config does not mention, all at their defaults:",
1175                unset.len()
1176            );
1177            for option in &unset {
1178                println!(
1179                    "      [{}] {} = {}",
1180                    option.section, option.key, option.default
1181                );
1182            }
1183            println!(
1184                "  `spar init --update {}` appends them as comments.",
1185                path.display()
1186            );
1187        }
1188    }
1189
1190    println!(
1191        "{}",
1192        if ok {
1193            "\nready"
1194        } else {
1195            "\nmissing prerequisites"
1196        }
1197    );
1198    Ok(if ok { 0 } else { 1 })
1199}
1200
1201fn first_line(text: &str) -> String {
1202    text.trim().lines().next().unwrap_or("").trim().to_string()
1203}
1204
1205// ---------------------------------------------------------------------------
1206// Reporting
1207// ---------------------------------------------------------------------------
1208
1209fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1210    println!("\n{}", "=".repeat(60));
1211    for r in results {
1212        println!(
1213            "#{:<5} {:<10} rounds={} {}",
1214            r.issue,
1215            r.status.to_string(),
1216            r.rounds,
1217            r.pr.as_deref().unwrap_or("")
1218        );
1219        for note in &r.notes {
1220            println!("       {}", first_line(note));
1221        }
1222        for url in &r.filed {
1223            println!("       filed {url}");
1224        }
1225        for dispute in &r.disputes {
1226            println!("       disputed: {}", dispute.title);
1227        }
1228    }
1229    println!("{}", "=".repeat(60));
1230
1231    if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1232        println!("\nApproved PRs are waiting on you to merge.");
1233    }
1234    let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1235    if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1236        println!(
1237            "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1238             Set followups = \"issues\" to file them."
1239        );
1240    }
1241    if results.iter().all(IssueRun::succeeded) {
1242        0
1243    } else {
1244        1
1245    }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251    use clap::CommandFactory;
1252
1253    #[test]
1254    fn the_parser_is_internally_consistent() {
1255        Cli::command().debug_assert();
1256    }
1257
1258    #[test]
1259    fn quiet_is_accepted_before_or_after_the_subcommand() {
1260        for argv in [
1261            vec!["spar", "--quiet", "run", "42"],
1262            vec!["spar", "run", "42", "--quiet"],
1263            vec!["spar", "resume", "--quiet"],
1264            vec!["spar", "init", "-q"],
1265        ] {
1266            assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1267        }
1268        assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1269    }
1270
1271    #[test]
1272    fn several_issue_numbers_are_accepted() {
1273        let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1274        match cli.command {
1275            Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1276            other => panic!("{other:?}"),
1277        }
1278    }
1279
1280    #[test]
1281    fn issue_numbers_and_flags_can_be_interleaved() {
1282        let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1283        match cli.command {
1284            Command::Run {
1285                issues, loop_flags, ..
1286            } => {
1287                assert_eq!(vec![42, 51], issues);
1288                assert!(loop_flags.auto_merge);
1289            }
1290            other => panic!("{other:?}"),
1291        }
1292    }
1293
1294    #[test]
1295    fn every_command_that_reads_a_config_accepts_one() {
1296        for argv in [
1297            vec!["spar", "run", "42"],
1298            vec!["spar", "triage"],
1299            vec!["spar", "resume"],
1300            vec!["spar", "clean"],
1301            vec!["spar", "doctor"],
1302        ] {
1303            let mut full = argv.clone();
1304            full.extend(["--config", "other.toml"]);
1305            let cli = Cli::parse_from(&full);
1306            let config = match cli.command {
1307                Command::Run { common, .. }
1308                | Command::Triage { common, .. }
1309                | Command::Resume { common, .. } => common.config,
1310                Command::Clean { config, .. } | Command::Doctor { config } => config,
1311                other => panic!("{other:?}"),
1312            };
1313            assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1314        }
1315    }
1316
1317    #[test]
1318    fn auto_merge_is_off_unless_asked_for() {
1319        let cli = Cli::parse_from(["spar", "run"]);
1320        match cli.command {
1321            Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1322            other => panic!("{other:?}"),
1323        }
1324    }
1325
1326    #[test]
1327    fn the_two_close_skipped_flags_are_mutually_exclusive() {
1328        assert!(
1329            Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1330        );
1331    }
1332
1333    /// Only `run` triages, so only `run` can decline an issue. Accepting the
1334    /// flag on `resume` would silently do nothing.
1335    #[test]
1336    fn close_skipped_is_offered_only_where_it_means_something() {
1337        assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1338        assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1339        assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1340        assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1341        assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1342    }
1343
1344    #[test]
1345    fn the_close_skipped_pair_resolves_to_a_tristate() {
1346        let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1347            Command::Run { triage_flags, .. } => {
1348                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1349                    (true, _) => Some(true),
1350                    (_, true) => Some(false),
1351                    _ => None,
1352                }
1353            }
1354            other => panic!("{other:?}"),
1355        };
1356        assert_eq!(None, read(&["spar", "run"]));
1357        assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1358        assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1359    }
1360
1361    #[test]
1362    fn the_default_limit_is_twenty() {
1363        let cli = Cli::parse_from(["spar", "run"]);
1364        match cli.command {
1365            Command::Run { common, .. } => assert_eq!(20, common.limit),
1366            other => panic!("{other:?}"),
1367        }
1368    }
1369
1370    #[test]
1371    fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1372        assert!(matches!(
1373            Cli::parse_from(["spar", "scrub-filter"]).command,
1374            Command::ScrubFilter
1375        ));
1376        let help = Cli::command().render_long_help().to_string();
1377        assert!(
1378            !help.contains("scrub-filter"),
1379            "it is plumbing, not a command"
1380        );
1381    }
1382
1383    #[test]
1384    fn review_takes_pr_numbers_and_a_dry_run() {
1385        let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1386        match cli.command {
1387            Command::Review { items, dry_run, .. } => {
1388                assert_eq!(vec![101, 102], items);
1389                assert!(dry_run);
1390            }
1391            other => panic!("{other:?}"),
1392        }
1393    }
1394
1395    #[test]
1396    fn review_posts_unless_told_not_to() {
1397        match Cli::parse_from(["spar", "review", "101"]).command {
1398            Command::Review { dry_run, .. } => assert!(!dry_run),
1399            other => panic!("{other:?}"),
1400        }
1401    }
1402
1403    #[test]
1404    fn review_with_no_numbers_is_allowed() {
1405        match Cli::parse_from(["spar", "review"]).command {
1406            Command::Review { items, .. } => assert!(items.is_empty()),
1407            other => panic!("{other:?}"),
1408        }
1409    }
1410
1411    #[test]
1412    fn review_takes_its_own_round_budget() {
1413        match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1414            Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1415            other => panic!("{other:?}"),
1416        }
1417    }
1418
1419    #[test]
1420    fn resume_takes_a_next_override() {
1421        let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1422        match cli.command {
1423            Command::Resume {
1424                prs, next_actor, ..
1425            } => {
1426                assert_eq!(vec![108], prs);
1427                assert_eq!(Some("codex".to_string()), next_actor);
1428            }
1429            other => panic!("{other:?}"),
1430        }
1431    }
1432}
1433
1434#[cfg(test)]
1435mod absorb_tests {
1436    use super::*;
1437
1438    #[test]
1439    fn absorb_is_off_unless_asked_for() {
1440        match Cli::parse_from(["spar", "run"]).command {
1441            Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1442            other => panic!("{other:?}"),
1443        }
1444    }
1445
1446    #[test]
1447    fn absorb_takes_a_wave_count() {
1448        match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1449            Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1450            other => panic!("{other:?}"),
1451        }
1452    }
1453
1454    #[test]
1455    fn absorb_is_only_offered_where_issues_are_worked() {
1456        assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1457        assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1458        assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1459    }
1460}
1461
1462#[cfg(test)]
1463mod min_number_tests {
1464    use super::*;
1465
1466    fn read(argv: &[&str]) -> Option<i64> {
1467        match Cli::parse_from(argv).command {
1468            Command::Run { common, .. }
1469            | Command::Triage { common, .. }
1470            | Command::Resume { common, .. }
1471            | Command::Review { common, .. } => common.min_number,
1472            other => panic!("{other:?}"),
1473        }
1474    }
1475
1476    #[test]
1477    fn there_is_no_floor_unless_one_is_asked_for() {
1478        assert_eq!(None, read(&["spar", "run"]));
1479    }
1480
1481    #[test]
1482    fn every_command_that_picks_for_itself_accepts_a_floor() {
1483        for cmd in ["run", "triage", "resume", "review"] {
1484            assert_eq!(
1485                Some(480),
1486                read(&["spar", cmd, "--min-number", "480"]),
1487                "{cmd}"
1488            );
1489        }
1490    }
1491}