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        if item.tracker {
704            log!(
705                "  hold #{} (both reviewers: tracks work filed elsewhere)",
706                item.issue
707            );
708        } else {
709            log!("  skip #{} (both reviewers: not worth doing)", item.issue);
710        }
711    }
712    for item in &plan.contested {
713        log!("  ??   #{} contested, parked for you to decide", item.issue);
714    }
715    Ok(plan)
716}
717
718/// Post the shared reasoning on every issue both agents declined, and close it
719/// when the config says so. Contested issues are never touched.
720///
721/// A tracker is never closed, whatever `close_skipped` says. Declining to open
722/// a pull request for an umbrella is right, and closing it does not follow from
723/// that: its parts are still open, and the shared context and the alternatives
724/// somebody recorded against are the reason the issue exists. spar closed a
725/// real one as "not planned" while all three of its subtasks were open, which
726/// is what this exists to stop.
727fn act_on_plan(cfg: &Config, repo: &Repo, plan: &Plan) {
728    for item in &plan.skipped {
729        let body = review::skip_comment(item, &repo.style);
730        let close = cfg.loop_cfg.close_skipped && !item.tracker;
731        let outcome = if close {
732            repo.close_issue(item.issue, &body)
733        } else {
734            repo.comment_issue(item.issue, &body)
735        };
736        match outcome {
737            Ok(()) if close => log!("  closed #{}", item.issue),
738            Ok(()) if item.tracker => {
739                log!(
740                    "  left #{} open, it tracks work filed elsewhere",
741                    item.issue
742                )
743            }
744            Ok(()) => {}
745            Err(e) => logdim!("could not update #{}: {e}", item.issue),
746        }
747    }
748}
749
750// ---------------------------------------------------------------------------
751// Subcommands
752// ---------------------------------------------------------------------------
753
754fn cmd_scrub_filter() -> Result<i32> {
755    let mut input = String::new();
756    std::io::stdin()
757        .read_to_string(&mut input)
758        .map_err(|e| spar_err!("could not read a commit message from stdin: {e}"))?;
759    let out = style::scrub(&input, &crate::repo::style_from_env());
760    let mut stdout = std::io::stdout();
761    stdout
762        .write_all(out.as_bytes())
763        .and_then(|_| stdout.write_all(b"\n"))
764        .map_err(|e| spar_err!("could not write the scrubbed message: {e}"))?;
765    Ok(0)
766}
767
768fn cmd_clean(
769    repo_path: &Path,
770    config_path: Option<&Path>,
771    all: bool,
772    pr_state: bool,
773) -> Result<i32> {
774    let cfg = config::load(config_path)?;
775    let repo = Repo::open(repo_path, &cfg)?;
776    let mut removed = repo.prune_worktrees(all);
777    removed.extend(repo.prune_state());
778    if pr_state {
779        removed.extend(repo.prune_pr_state(None));
780    }
781    if removed.is_empty() {
782        println!("nothing to clean");
783    } else {
784        for item in removed {
785            println!("removed {item}");
786        }
787    }
788    Ok(0)
789}
790
791/// Post a review that was produced earlier and not sent.
792fn cmd_post(
793    prs: &[i64],
794    repo_path: &Path,
795    config_path: Option<&Path>,
796    file: Option<&Path>,
797    dry_run: bool,
798) -> Result<i32> {
799    let cfg = config::load(config_path)?;
800    let repo = Repo::open(repo_path, &cfg)?;
801
802    if file.is_some() && prs.len() > 1 {
803        bail!("--file posts one review, so give it one pull request number");
804    }
805
806    let mut failed = false;
807    for number in prs {
808        let text = match file {
809            Some(path) => std::fs::read_to_string(path)
810                .map_err(|e| spar_err!("could not read {}: {e}", path.display()))?,
811            None => match repo.read_pending_comment(*number) {
812                Some(text) => text,
813                None => {
814                    logging::error(format!(
815                        "no saved review for PR #{number}. `spar review {number} --dry-run` \
816                         produces one, or pass --file."
817                    ));
818                    failed = true;
819                    continue;
820                }
821            },
822        };
823        if text.trim().is_empty() {
824            logging::error(format!("the saved review for PR #{number} is empty"));
825            failed = true;
826            continue;
827        }
828        if dry_run {
829            println!("\n{}\n", text.trim());
830            log!("would post the above to PR #{number}");
831            continue;
832        }
833        // Through the style gate like anything else spar sends, so an edit that
834        // reintroduces a banned dash is caught rather than published.
835        match repo.comment_pr(*number, &text) {
836            Ok(()) => log!("posted to PR #{number}"),
837            Err(e) => {
838                logging::error(format!("could not post to PR #{number}: {e}"));
839                failed = true;
840            }
841        }
842    }
843    Ok(if failed { 1 } else { 0 })
844}
845
846/// Append the settings a config does not mention, commented out.
847///
848/// Append only by design. Rewriting somebody's config to insert options would
849/// take their comments and their ordering with it, and `--force` already exists
850/// for anyone who wants the generated file back.
851fn cmd_init_update(out: &Path) -> Result<i32> {
852    let text = std::fs::read_to_string(out)
853        .map_err(|e| spar_err!("could not read {}: {e}", out.display()))?;
854    // Refuse to append to something that does not parse, rather than making a
855    // broken config longer.
856    config::parse(&text).map_err(|e| spar_err!("{} does not parse: {e}", out.display()))?;
857
858    let unset = config::unmentioned_options(&text);
859    if unset.is_empty() {
860        println!("{} already mentions every setting.", out.display());
861        return Ok(0);
862    }
863
864    let mut block = String::new();
865    if !text.ends_with('\n') {
866        block.push('\n');
867    }
868    block.push_str("\n# Added by `spar init --update`: settings this file did not mention,\n");
869    block.push_str("# shown at their defaults. Uncomment one to change it.\n");
870    let mut section = "";
871    for option in &unset {
872        if option.section != section {
873            section = option.section;
874            block.push_str(&format!("# [{section}]\n"));
875        }
876        block.push_str(&format!("# {} = {}\n", option.key, option.default));
877    }
878
879    use std::io::Write;
880    std::fs::OpenOptions::new()
881        .append(true)
882        .open(out)
883        .and_then(|mut f| f.write_all(block.as_bytes()))
884        .map_err(|e| spar_err!("could not append to {}: {e}", out.display()))?;
885
886    println!(
887        "added {} setting(s) to {} as comments",
888        unset.len(),
889        out.display()
890    );
891    Ok(0)
892}
893
894fn cmd_init(out: &Path, force: bool) -> Result<i32> {
895    if out.exists() && !force {
896        logging::error(format!(
897            "{} already exists. `--update` appends any settings it does not mention, \
898             `--force` overwrites it.",
899            out.display()
900        ));
901        return Ok(1);
902    }
903
904    let presets = config::available_presets();
905    if presets.is_empty() {
906        bail!("no presets available, which should be impossible in a released build");
907    }
908
909    let mut found: Vec<(String, PathBuf, config::AgentSpec)> = Vec::new();
910    for name in &presets {
911        let raw = config::load_preset(name)?;
912        // A preset that will not build is a broken preset, not an uninstalled
913        // CLI. Skipping it silently reported it as "missing" and sent people
914        // looking for an install problem that was not there.
915        let mut spec: config::AgentSpec = match raw
916            .as_table()
917            .cloned()
918            .ok_or_else(|| spar_err!("not a table"))
919            .and_then(|t| {
920                toml::Value::Table(t)
921                    .try_into()
922                    .map_err(|e| spar_err!("{e}"))
923            }) {
924            Ok(spec) => spec,
925            Err(e) => {
926                println!("  BROKEN   {name:10} {}", e.first_line());
927                continue;
928            }
929        };
930        spec.name = name.clone();
931        match Agent::new(spec.clone()).resolve_bin() {
932            Ok(path) => {
933                println!("  found    {name:10} {}", path.display());
934                found.push((name.clone(), path.to_path_buf(), spec));
935            }
936            Err(_) => println!("  missing  {name}"),
937        }
938    }
939
940    if found.len() < 2 {
941        logging::error(format!(
942            "need two agent CLIs, found {}. Install another, or write {} by hand using the \
943             presets as a reference.",
944            found.len(),
945            out.display()
946        ));
947        return Ok(1);
948    }
949
950    // Prefer a pair that cannot share blind spots, if one is available.
951    let chosen: Vec<&(String, PathBuf, config::AgentSpec)> = found.iter().take(2).collect();
952    if found.len() > 2 {
953        log!(
954            "{} agents available, picking {} and {}. Edit {} to change.",
955            found.len(),
956            chosen[0].0,
957            chosen[1].0,
958            out.display()
959        );
960    }
961
962    let mut text = String::from(
963        "# Generated by `spar init`. Each agent inherits a command template from a\n\
964         # built in preset; anything set here overrides it.\n\
965         #\n\
966         # Commented lines are the other options, each with a working value.\n\
967         # Uncomment one to change it.\n\n",
968    );
969    for (name, _, spec) in &chosen {
970        text.push_str(&agent_block(name, spec));
971    }
972    text.push_str(&settings_block(&chosen[0].0));
973
974    std::fs::write(out, text).map_err(|e| spar_err!("could not write {}: {e}", out.display()))?;
975    println!("\nwrote {}", out.display());
976    println!("Next: `spar doctor` to check it, then `spar run` in a repo you have push access to.");
977    Ok(0)
978}
979
980/// An agent's stand in, under the agent it stands in for.
981///
982/// Never counted against `doctor`'s exit code, deliberately. A fallback that is
983/// not installed does not stop a run either, and a check that disagrees with
984/// the runtime teaches people to ignore it.
985fn report_fallback(agent: &Agent) {
986    let Some(backup) = agent.fallback() else {
987        return;
988    };
989    match backup.resolve_bin() {
990        Ok(bin) => println!(
991            "        fallback    {}  ({})",
992            bin.display(),
993            backup.spec.describe()
994        ),
995        Err(_) => println!(
996            "        fallback    {} not found, so it will not stand in. Set {} to its path.",
997            backup.program(),
998            backup.env_key()
999        ),
1000    }
1001}
1002
1003/// One settable option: whether the generated config leaves it commented out,
1004/// its key, and the note beside it.
1005///
1006/// The value is deliberately absent. Every value comes from the defaults
1007/// themselves, because a value typed in here is a second copy of a number that
1008/// lives somewhere else, and the second copy is the one that goes stale. This
1009/// one did: the generated config offered a title budget of 90, a summary of
1010/// 200, a detail of 320, a body of 900 and an issue body of 4000, long after
1011/// those became 140, 2000, 6000, 8000 and 20000. Uncommenting a line to see
1012/// what it did cut every comment spar posts to a fifth of its length.
1013type Setting = (bool, &'static str, &'static str);
1014
1015const LOOP_OPTIONS: &[Setting] = &[
1016    (
1017        false,
1018        "max_rounds",
1019        "review rounds ONE invocation may spend. Resuming grants a fresh budget, so this is not a lifetime cap on a PR.",
1020    ),
1021    (
1022        false,
1023        "auto_merge",
1024        "off on purpose: two models agreeing is not the same as being right",
1025    ),
1026    (false, "first_implementor", ""),
1027    (false, "worktrees", "false works in the main checkout"),
1028    (
1029        false,
1030        "close_skipped",
1031        "close an issue both reviewers declined",
1032    ),
1033    (
1034        false,
1035        "followups",
1036        "issues | local | none. local writes .spar/followups.md, not the tracker",
1037    ),
1038    (
1039        true,
1040        "file_non_blocking",
1041        "a suggestion is not a tracker item",
1042    ),
1043    (
1044        true,
1045        "max_followups",
1046        "backstop on what one run can spawn",
1047    ),
1048    (
1049        true,
1050        "keep_worktrees",
1051        "true leaves them behind to inspect",
1052    ),
1053    (
1054        true,
1055        "min_number",
1056        "ignore anything numbered below this when picking for itself. 0 is no floor.",
1057    ),
1058    (
1059        true,
1060        "parallel_triage",
1061        "false asks the agents one at a time",
1062    ),
1063    (
1064        true,
1065        "absorb_new_issues",
1066        "waves of newly filed follow-ups to fold back into this run. Costs more.",
1067    ),
1068    (true, "file_nits", "true files nits as issues too"),
1069    (
1070        true,
1071        "base_branch",
1072        "only a fallback; origin/HEAD wins when it resolves",
1073    ),
1074    (
1075        true,
1076        "branch_prefix",
1077        "e.g. \"spar/\" to namespace the branches spar creates",
1078    ),
1079    (true, "state_store", "local | pr | both"),
1080    (
1081        true,
1082        "max_issue_chars",
1083        "most of one issue body a prompt carries. Sized so nothing a person wrote is cut, and a cut is said out loud when it happens.",
1084    ),
1085    (
1086        true,
1087        "max_triage_chars",
1088        "most every issue body together may add to one triage prompt. Past it, whole issues wait for the next run rather than all of them losing their tails.",
1089    ),
1090];
1091
1092const STYLE_OPTIONS: &[Setting] = &[
1093    (false, "ban_em_dash", ""),
1094    (false, "ban_ai_attribution", ""),
1095    (
1096        false,
1097        "terse",
1098        "hold model prose to a length budget. false removes the valves entirely",
1099    ),
1100    (
1101        true,
1102        "pr_comments",
1103        "outcome | rounds | none. How much of its own working spar narrates into a PR thread. none never comments at all.",
1104    ),
1105    (
1106        true,
1107        "max_title_chars",
1108        "a finding, issue, or PR title. Never ellipsised",
1109    ),
1110    (
1111        true,
1112        "max_summary_chars",
1113        "a one line verdict or refutation",
1114    ),
1115    (
1116        true,
1117        "max_detail_chars",
1118        "a blocking finding, in the PR thread",
1119    ),
1120    (true, "max_body_chars", "a PR body"),
1121    (
1122        true,
1123        "max_issue_body_chars",
1124        "a filed issue's body. Far larger on purpose: an issue is picked up cold. Fenced code blocks in one are never truncated and never count against this.",
1125    ),
1126];
1127
1128/// The `[loop]` and `[style]` blocks of a generated config.
1129///
1130/// Safety valves, not editors: the length budgets here are sized so real
1131/// content is never touched, which is why they read as large numbers.
1132fn settings_block(first_implementor: &str) -> String {
1133    let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1134        .into_iter()
1135        .map(|option| (option.key, option.default))
1136        .collect();
1137    // first_implementor has no default: it is whichever agent was written
1138    // first, and until there is a config there is no answer to give.
1139    let value = |key: &str| match key {
1140        "first_implementor" => format!("\"{first_implementor}\""),
1141        other => defaults.get(other).cloned().unwrap_or_default(),
1142    };
1143
1144    let mut out = String::from("[loop]\n");
1145    out.push_str(&option_lines(LOOP_OPTIONS, &value));
1146    out.push_str(concat!(
1147        "\n[loop.effort_schedule]\n",
1148        "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1149        "# these are examples rather than defaults. Left out, each agent uses\n",
1150        "# the effort its own block asked for.\n",
1151        "# round_1 = \"high\"   # the deep first review\n",
1152        "# rest    = \"low\"    # later rounds only see a small delta\n\n",
1153    ));
1154    out.push_str("[style]\n");
1155    out.push_str(&option_lines(STYLE_OPTIONS, &value));
1156    out
1157}
1158
1159/// Option lines with their notes lined up in a column, a long note wrapping
1160/// onto continuation lines that stay in the column rather than running off the
1161/// edge or restarting at the margin.
1162fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1163    let rows: Vec<(String, String)> = options
1164        .iter()
1165        .map(|(commented, key, note)| {
1166            let lead = if *commented { "# " } else { "" };
1167            (format!("{lead}{key} = {}", value(key)), note.to_string())
1168        })
1169        .collect();
1170    aligned(&rows)
1171}
1172
1173/// Assignments with their notes lined up in one column, a long note wrapping
1174/// onto continuation lines that stay in the column rather than running off the
1175/// edge or restarting at the margin.
1176///
1177/// Shared by the `[loop]` and `[style]` blocks and by an agent's own, which is
1178/// how a list of eight effort levels beside a long model name stays inside a
1179/// line somebody can read.
1180fn aligned(rows: &[(String, String)]) -> String {
1181    const WIDTH: usize = 78;
1182
1183    let column = rows
1184        .iter()
1185        .map(|(assignment, _)| assignment.chars().count())
1186        .max()
1187        .unwrap_or(0)
1188        + 2;
1189
1190    let mut out = String::new();
1191    for (assignment, note) in rows {
1192        if note.is_empty() {
1193            out.push_str(assignment);
1194            out.push('\n');
1195            continue;
1196        }
1197        let mut first = true;
1198        let mut line = String::new();
1199        for word in note.split_whitespace() {
1200            let would_be = column + 2 + line.chars().count() + 1 + word.chars().count();
1201            if !line.is_empty() && would_be > WIDTH {
1202                out.push_str(&noted(assignment, &line, column, &mut first));
1203                line.clear();
1204            }
1205            if !line.is_empty() {
1206                line.push(' ');
1207            }
1208            line.push_str(word);
1209        }
1210        if !line.is_empty() {
1211            out.push_str(&noted(assignment, &line, column, &mut first));
1212        }
1213    }
1214    out
1215}
1216
1217/// One rendered line: the assignment and the start of its note, then the rest
1218/// of the note alone, indented to the same column so it reads as one paragraph.
1219fn noted(assignment: &str, note: &str, column: usize, first: &mut bool) -> String {
1220    let lead = if *first {
1221        let pad = column.saturating_sub(assignment.chars().count());
1222        format!("{assignment}{}", " ".repeat(pad))
1223    } else {
1224        " ".repeat(column)
1225    };
1226    *first = false;
1227    format!("{lead}# {note}\n")
1228}
1229
1230/// One prerequisite check: a label and something that either reports a version
1231/// or explains what is missing.
1232type Probe = Box<dyn Fn() -> Result<String>>;
1233
1234/// One agent's block, with the options commented out beside a working value.
1235///
1236/// The values come from the preset rather than from here, so a CLI that adds a
1237/// model is a file edit. They are hints only: nothing validates against them,
1238/// because a stale list that refused a model which actually works would be
1239/// worse than no hint at all.
1240fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1241    let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1242
1243    // Only what the preset has hints for. An option with none used to be
1244    // written as `# effort = "..."`, and a placeholder is not a working value:
1245    // the line fails the moment somebody takes the file at its word and
1246    // uncomments it. Cursor has no effort setting at all, so for that agent the
1247    // line should not exist rather than exist and be wrong.
1248    //
1249    // The first entry of each list is the one written as the suggested value,
1250    // which is why the presets put the sensible default there rather than in
1251    // whatever order a CLI's help happens to print.
1252    let offered: Vec<(&str, &[String])> = [
1253        ("model ", spec.models.as_slice()),
1254        ("effort", spec.efforts.as_slice()),
1255    ]
1256    .into_iter()
1257    .filter(|(_, choices)| !choices.is_empty())
1258    .collect();
1259
1260    if !offered.is_empty() {
1261        let named: Vec<&str> = offered.iter().map(|(key, _)| key.trim()).collect();
1262        out.push_str(&format!(
1263            "# Omit {} to use the CLI's own default.\n",
1264            named.join(" or ")
1265        ));
1266
1267        // The alternatives sit beside the suggestion, so somebody editing the
1268        // file can see what else the CLI takes without going to look it up.
1269        // Nothing beside a single choice: there is nothing to choose.
1270        let rows: Vec<(String, String)> = offered
1271            .iter()
1272            .map(|(key, choices)| {
1273                let note = if choices.len() > 1 {
1274                    choices.join(" | ")
1275                } else {
1276                    String::new()
1277                };
1278                (format!("# {key} = \"{}\"", choices[0]), note)
1279            })
1280            .collect();
1281        out.push_str(&aligned(&rows));
1282    }
1283
1284    if let Some(note) = &spec.options_note {
1285        out.push_str(&wrap_comment(note));
1286    }
1287    // Anything but this agent's own preset: a CLI that has just refused is not
1288    // a stand in for itself.
1289    let backup = if name == "cursor" { "gemini" } else { "cursor" };
1290    out.push_str("# A stand in for when this CLI refuses, stalls, or runs out of quota.\n");
1291    out.push_str("# It answers in place of this agent, never alongside it.\n");
1292    out.push_str(&format!(
1293        "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1294    ));
1295    out.push('\n');
1296    out
1297}
1298
1299/// Wrap a note across comment lines so a long one does not run off the edge.
1300fn wrap_comment(text: &str) -> String {
1301    const WIDTH: usize = 76;
1302    let mut out = String::new();
1303    let mut line = String::from("#");
1304    for word in text.split_whitespace() {
1305        if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1306            out.push_str(&line);
1307            out.push('\n');
1308            line = String::from("#");
1309        }
1310        line.push(' ');
1311        line.push_str(word);
1312    }
1313    if line.len() > 1 {
1314        out.push_str(&line);
1315        out.push('\n');
1316    }
1317    out
1318}
1319
1320fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1321    let mut ok = true;
1322
1323    let probes: Vec<(&str, Probe)> = vec![
1324        (
1325            "git",
1326            Box::new(|| {
1327                proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1328                    .map(|s| first_line(&s))
1329            }),
1330        ),
1331        (
1332            "gh",
1333            Box::new(|| {
1334                proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1335                    .map(|s| first_line(&s))
1336            }),
1337        ),
1338        (
1339            "gh auth",
1340            Box::new(|| {
1341                let out = proc::exec(
1342                    &["gh".into(), "auth".into(), "status".into()],
1343                    &ExecOpts::new().check(false).timeout_secs(60),
1344                )?;
1345                let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1346                if out.ok() {
1347                    Ok(first_line(&text))
1348                } else {
1349                    Err(spar_err!("not authenticated. Run `gh auth login`."))
1350                }
1351            }),
1352        ),
1353    ];
1354
1355    for (label, probe) in probes {
1356        match probe() {
1357            Ok(detail) => println!("  ok    {label:12} {detail}"),
1358            Err(e) => {
1359                println!("  FAIL  {label:12} {}", e.first_line());
1360                ok = false;
1361            }
1362        }
1363    }
1364
1365    let found = config::find_config(config_path)?;
1366    let Some(path) = found else {
1367        println!("\n  no spar.toml found. Run `spar init` to generate one.");
1368        println!(
1369            "  presets available: {}",
1370            config::available_presets().join(", ")
1371        );
1372        return Ok(if ok { 0 } else { 1 });
1373    };
1374
1375    println!("\n  config: {}", path.display());
1376    let cfg = match config::load(Some(&path)) {
1377        Ok(cfg) => cfg,
1378        Err(e) => {
1379            println!("  FAIL  config       {e}");
1380            return Ok(1);
1381        }
1382    };
1383
1384    // Kept apart from `ok`: a missing gh says nothing about whether the two
1385    // agents are the same CLI, and must not silence the warning below.
1386    let mut resolved = Vec::new();
1387    for spec in &cfg.agents {
1388        let agent = Agent::new(spec.clone());
1389        match agent.resolve_bin() {
1390            Ok(bin) => {
1391                println!(
1392                    "  ok    {:12} {}  ({})",
1393                    spec.name,
1394                    bin.display(),
1395                    spec.describe()
1396                );
1397                report_fallback(&agent);
1398                resolved.push(agent);
1399            }
1400            Err(e) => {
1401                println!("  FAIL  {:12} {}", spec.name, e.first_line());
1402                ok = false;
1403            }
1404        }
1405    }
1406
1407    if resolved.len() == cfg.agents.len() {
1408        if let Some(warning) = agent::correlation_warning(&resolved) {
1409            println!("\n  WARNING  {warning}");
1410        }
1411    }
1412
1413    println!(
1414        "\n  settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1415        cfg.loop_cfg.max_rounds,
1416        cfg.loop_cfg.auto_merge,
1417        cfg.loop_cfg.worktrees,
1418        cfg.loop_cfg.followups,
1419        cfg.style.terse
1420    );
1421    // What somebody upgrading wants to know. `spar init` refuses to touch an
1422    // existing config, so without this there is no way to learn that a release
1423    // added a setting short of reading the source.
1424    if let Ok(text) = std::fs::read_to_string(&path) {
1425        let unset = config::unmentioned_options(&text);
1426        if !unset.is_empty() {
1427            println!(
1428                "\n  {} setting(s) this config does not mention, all at their defaults:",
1429                unset.len()
1430            );
1431            for option in &unset {
1432                println!(
1433                    "      [{}] {} = {}",
1434                    option.section, option.key, option.default
1435                );
1436            }
1437            println!(
1438                "  `spar init --update {}` appends them as comments.",
1439                path.display()
1440            );
1441        }
1442    }
1443
1444    println!(
1445        "{}",
1446        if ok {
1447            "\nready"
1448        } else {
1449            "\nmissing prerequisites"
1450        }
1451    );
1452    Ok(if ok { 0 } else { 1 })
1453}
1454
1455fn first_line(text: &str) -> String {
1456    text.trim().lines().next().unwrap_or("").trim().to_string()
1457}
1458
1459// ---------------------------------------------------------------------------
1460// Reporting
1461// ---------------------------------------------------------------------------
1462
1463fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1464    println!("\n{}", "=".repeat(60));
1465    for r in results {
1466        println!(
1467            "#{:<5} {:<10} rounds={} {}",
1468            r.issue,
1469            r.status.to_string(),
1470            r.rounds,
1471            r.pr.as_deref().unwrap_or("")
1472        );
1473        for note in &r.notes {
1474            println!("       {}", first_line(note));
1475        }
1476        for url in &r.filed {
1477            println!("       filed {url}");
1478        }
1479        for dispute in &r.disputes {
1480            println!("       disputed: {}", dispute.title);
1481        }
1482    }
1483    println!("{}", "=".repeat(60));
1484
1485    if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1486        println!("\nApproved PRs are waiting on you to merge.");
1487    }
1488    let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1489    if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1490        println!(
1491            "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1492             Set followups = \"issues\" to file them."
1493        );
1494    }
1495    if results.iter().all(IssueRun::succeeded) {
1496        0
1497    } else {
1498        1
1499    }
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504    use super::*;
1505    use clap::CommandFactory;
1506
1507    #[test]
1508    fn the_parser_is_internally_consistent() {
1509        Cli::command().debug_assert();
1510    }
1511
1512    #[test]
1513    fn quiet_is_accepted_before_or_after_the_subcommand() {
1514        for argv in [
1515            vec!["spar", "--quiet", "run", "42"],
1516            vec!["spar", "run", "42", "--quiet"],
1517            vec!["spar", "resume", "--quiet"],
1518            vec!["spar", "init", "-q"],
1519        ] {
1520            assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1521        }
1522        assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1523    }
1524
1525    #[test]
1526    fn several_issue_numbers_are_accepted() {
1527        let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1528        match cli.command {
1529            Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1530            other => panic!("{other:?}"),
1531        }
1532    }
1533
1534    #[test]
1535    fn issue_numbers_and_flags_can_be_interleaved() {
1536        let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1537        match cli.command {
1538            Command::Run {
1539                issues, loop_flags, ..
1540            } => {
1541                assert_eq!(vec![42, 51], issues);
1542                assert!(loop_flags.auto_merge);
1543            }
1544            other => panic!("{other:?}"),
1545        }
1546    }
1547
1548    #[test]
1549    fn every_command_that_reads_a_config_accepts_one() {
1550        for argv in [
1551            vec!["spar", "run", "42"],
1552            vec!["spar", "triage"],
1553            vec!["spar", "resume"],
1554            vec!["spar", "clean"],
1555            vec!["spar", "doctor"],
1556        ] {
1557            let mut full = argv.clone();
1558            full.extend(["--config", "other.toml"]);
1559            let cli = Cli::parse_from(&full);
1560            let config = match cli.command {
1561                Command::Run { common, .. }
1562                | Command::Triage { common, .. }
1563                | Command::Resume { common, .. } => common.config,
1564                Command::Clean { config, .. } | Command::Doctor { config } => config,
1565                other => panic!("{other:?}"),
1566            };
1567            assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1568        }
1569    }
1570
1571    #[test]
1572    fn auto_merge_is_off_unless_asked_for() {
1573        let cli = Cli::parse_from(["spar", "run"]);
1574        match cli.command {
1575            Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1576            other => panic!("{other:?}"),
1577        }
1578    }
1579
1580    #[test]
1581    fn the_two_close_skipped_flags_are_mutually_exclusive() {
1582        assert!(
1583            Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1584        );
1585    }
1586
1587    /// Only `run` triages, so only `run` can decline an issue. Accepting the
1588    /// flag on `resume` would silently do nothing.
1589    #[test]
1590    fn close_skipped_is_offered_only_where_it_means_something() {
1591        assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1592        assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1593        assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1594        assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1595        assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1596    }
1597
1598    #[test]
1599    fn the_close_skipped_pair_resolves_to_a_tristate() {
1600        let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1601            Command::Run { triage_flags, .. } => {
1602                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1603                    (true, _) => Some(true),
1604                    (_, true) => Some(false),
1605                    _ => None,
1606                }
1607            }
1608            other => panic!("{other:?}"),
1609        };
1610        assert_eq!(None, read(&["spar", "run"]));
1611        assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1612        assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1613    }
1614
1615    #[test]
1616    fn the_default_limit_is_twenty() {
1617        let cli = Cli::parse_from(["spar", "run"]);
1618        match cli.command {
1619            Command::Run { common, .. } => assert_eq!(20, common.limit),
1620            other => panic!("{other:?}"),
1621        }
1622    }
1623
1624    #[test]
1625    fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1626        assert!(matches!(
1627            Cli::parse_from(["spar", "scrub-filter"]).command,
1628            Command::ScrubFilter
1629        ));
1630        let help = Cli::command().render_long_help().to_string();
1631        assert!(
1632            !help.contains("scrub-filter"),
1633            "it is plumbing, not a command"
1634        );
1635    }
1636
1637    #[test]
1638    fn review_takes_pr_numbers_and_a_dry_run() {
1639        let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1640        match cli.command {
1641            Command::Review { items, dry_run, .. } => {
1642                assert_eq!(vec![101, 102], items);
1643                assert!(dry_run);
1644            }
1645            other => panic!("{other:?}"),
1646        }
1647    }
1648
1649    #[test]
1650    fn review_posts_unless_told_not_to() {
1651        match Cli::parse_from(["spar", "review", "101"]).command {
1652            Command::Review { dry_run, .. } => assert!(!dry_run),
1653            other => panic!("{other:?}"),
1654        }
1655    }
1656
1657    #[test]
1658    fn review_with_no_numbers_is_allowed() {
1659        match Cli::parse_from(["spar", "review"]).command {
1660            Command::Review { items, .. } => assert!(items.is_empty()),
1661            other => panic!("{other:?}"),
1662        }
1663    }
1664
1665    #[test]
1666    fn review_takes_its_own_round_budget() {
1667        match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1668            Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1669            other => panic!("{other:?}"),
1670        }
1671    }
1672
1673    #[test]
1674    fn resume_takes_a_next_override() {
1675        let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1676        match cli.command {
1677            Command::Resume {
1678                prs, next_actor, ..
1679            } => {
1680                assert_eq!(vec![108], prs);
1681                assert_eq!(Some("codex".to_string()), next_actor);
1682            }
1683            other => panic!("{other:?}"),
1684        }
1685    }
1686}
1687
1688#[cfg(test)]
1689mod absorb_tests {
1690    use super::*;
1691
1692    #[test]
1693    fn absorb_is_off_unless_asked_for() {
1694        match Cli::parse_from(["spar", "run"]).command {
1695            Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1696            other => panic!("{other:?}"),
1697        }
1698    }
1699
1700    #[test]
1701    fn absorb_takes_a_wave_count() {
1702        match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1703            Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1704            other => panic!("{other:?}"),
1705        }
1706    }
1707
1708    #[test]
1709    fn absorb_is_only_offered_where_issues_are_worked() {
1710        assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1711        assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1712        assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1713    }
1714}
1715
1716#[cfg(test)]
1717mod min_number_tests {
1718    use super::*;
1719
1720    fn read(argv: &[&str]) -> Option<i64> {
1721        match Cli::parse_from(argv).command {
1722            Command::Run { common, .. }
1723            | Command::Triage { common, .. }
1724            | Command::Resume { common, .. }
1725            | Command::Review { common, .. } => common.min_number,
1726            other => panic!("{other:?}"),
1727        }
1728    }
1729
1730    #[test]
1731    fn there_is_no_floor_unless_one_is_asked_for() {
1732        assert_eq!(None, read(&["spar", "run"]));
1733    }
1734
1735    #[test]
1736    fn every_command_that_picks_for_itself_accepts_a_floor() {
1737        for cmd in ["run", "triage", "resume", "review"] {
1738            assert_eq!(
1739                Some(480),
1740                read(&["spar", cmd, "--min-number", "480"]),
1741                "{cmd}"
1742            );
1743        }
1744    }
1745}
1746
1747#[cfg(test)]
1748mod settings_block_tests {
1749    use super::*;
1750
1751    /// The value a config line offers, with its trailing note removed. Quote
1752    /// aware, since a note is free to contain a `#` and several do.
1753    fn written(line: &str) -> String {
1754        let after = line.split_once('=').expect("an assignment").1;
1755        let mut quoted = false;
1756        for (i, c) in after.char_indices() {
1757            match c {
1758                '"' => quoted = !quoted,
1759                '#' if !quoted => return after[..i].trim().to_string(),
1760                _ => {}
1761            }
1762        }
1763        after.trim().to_string()
1764    }
1765
1766    fn line_for(text: &str, key: &str) -> String {
1767        text.lines()
1768            .find(|l| {
1769                let bare = l.trim_start().trim_start_matches('#').trim_start();
1770                bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1771            })
1772            .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
1773            .to_string()
1774    }
1775
1776    /// The guard that was missing. Every number in the generated config used to
1777    /// be typed in beside its comment, which is a second copy of a default that
1778    /// lives in the code, and the copies stopped agreeing: it offered a title
1779    /// budget of 90 against a real 140, a body of 900 against 8000, and three
1780    /// more like it. Uncommenting one to see what it did cut every comment spar
1781    /// posts to a fifth of its length.
1782    #[test]
1783    fn every_value_it_offers_is_the_default_it_actually_has() {
1784        let text = settings_block("claude");
1785        for option in config::known_options() {
1786            // The effort words are per CLI, so the schedule's are examples of
1787            // what one accepts rather than defaults. There is no default
1788            // effort: an agent that names none uses its own CLI's.
1789            if option.section == "loop.effort_schedule" {
1790                continue;
1791            }
1792            let line = line_for(&text, &option.key);
1793            assert_eq!(
1794                option.default,
1795                written(&line),
1796                "the generated config offers `{}`, but the default is {}",
1797                line.trim(),
1798                option.default
1799            );
1800        }
1801    }
1802
1803    /// `doctor` reports what a config does not mention, so a generated one
1804    /// should send nobody to that list on the day it was written. pr_comments
1805    /// was missing from it for exactly that long.
1806    #[test]
1807    fn it_offers_every_option_the_parser_knows_about() {
1808        let text = settings_block("claude");
1809        let missing: Vec<String> = config::unmentioned_options(&text)
1810            .into_iter()
1811            .map(|o| format!("[{}] {}", o.section, o.key))
1812            .collect();
1813        assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
1814    }
1815
1816    /// The strongest of these: every line the file suggests has to be a line
1817    /// that works. A commented option is an invitation to uncomment it, and one
1818    /// that then fails to load is worse than never having offered it.
1819    #[test]
1820    fn every_option_it_offers_can_be_uncommented_and_still_load() {
1821        let mut text = String::from(
1822            "[agents.claude]\ncommand = [\"claude\"]\n\n\
1823             [agents.codex]\ncommand = [\"codex\"]\n\n",
1824        );
1825        for line in settings_block("claude").lines() {
1826            text.push_str(uncomment(line).unwrap_or(line));
1827            text.push('\n');
1828        }
1829        let cfg = config::parse(&text).expect("a config of its own suggestions");
1830        assert_eq!("claude", cfg.first_implementor);
1831    }
1832
1833    /// A commented assignment with its `#` removed, or None for a line of
1834    /// prose, which stays a comment.
1835    fn uncomment(line: &str) -> Option<&str> {
1836        let bare = line.trim_start().strip_prefix('#')?.trim_start();
1837        // An assignment, not a wrapped note that happens to contain an `=`:
1838        // the key has to be one bare word.
1839        let key = bare.split_once('=')?.0.trim();
1840        let named = !key.is_empty()
1841            && key
1842                .chars()
1843                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1844        named.then_some(bare)
1845    }
1846
1847    #[test]
1848    fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
1849        assert!(settings_block("codex").contains("first_implementor = \"codex\""));
1850    }
1851
1852    /// A note long enough to wrap stays in its column rather than restarting at
1853    /// the margin, where it would read as a new option.
1854    #[test]
1855    fn a_wrapped_note_stays_in_its_column() {
1856        let text = settings_block("claude");
1857        let column = text
1858            .lines()
1859            .find(|l| l.starts_with("max_rounds"))
1860            .and_then(|l| l.find('#'))
1861            .expect("a note on max_rounds");
1862        let continuation = text
1863            .lines()
1864            .find(|l| l.starts_with("    ") && l.trim_start().starts_with('#'))
1865            .expect("a wrapped note");
1866        assert_eq!(Some(column), continuation.find('#'));
1867        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text}");
1868    }
1869}
1870
1871#[cfg(test)]
1872mod agent_block_tests {
1873    use super::*;
1874
1875    fn spec(models: &[&str], efforts: &[&str]) -> config::AgentSpec {
1876        let mut spec: config::AgentSpec =
1877            toml::Value::Table(toml::from_str("command = [\"x\"]").expect("a minimal preset"))
1878                .try_into()
1879                .expect("builds");
1880        spec.models = models.iter().map(|s| s.to_string()).collect();
1881        spec.efforts = efforts.iter().map(|s| s.to_string()).collect();
1882        spec
1883    }
1884
1885    /// A placeholder is not a working value. `# effort = "..."` was written for
1886    /// every preset that lists no efforts, and taking the file at its word by
1887    /// uncommenting it passes `...` to the CLI as a real setting.
1888    #[test]
1889    fn an_option_with_no_hints_is_left_out_rather_than_guessed_at() {
1890        let block = agent_block("cursor", &spec(&["composer-2.5", "auto"], &[]));
1891        assert!(!block.contains("..."), "{block}");
1892        assert!(!block.contains("effort"), "{block}");
1893        assert!(block.contains("# model  = \"composer-2.5\""), "{block}");
1894    }
1895
1896    /// The header has to name what is actually below it. Saying "model or
1897    /// effort" over a block with no effort line sends somebody looking for a
1898    /// setting the CLI does not have.
1899    #[test]
1900    fn the_header_names_only_the_options_that_follow() {
1901        assert!(agent_block("cursor", &spec(&["auto"], &[])).contains("Omit model to use"));
1902        assert!(
1903            agent_block("claude", &spec(&["fable"], &["high"])).contains("Omit model or effort")
1904        );
1905    }
1906
1907    /// A preset with no hints at all still has to produce a loadable block,
1908    /// which is every preset that has never listed any: gemini and aider.
1909    #[test]
1910    fn a_preset_with_no_hints_still_writes_a_usable_block() {
1911        let block = agent_block("gemini", &spec(&[], &[]));
1912        assert!(!block.contains("..."), "{block}");
1913        assert!(!block.contains("Omit"), "{block}");
1914        assert!(
1915            block.starts_with("[agents.gemini]\npreset = \"gemini\"\n"),
1916            "{block}"
1917        );
1918        // What the block does still carry is unaffected.
1919        assert!(block.contains("[agents.gemini.fallback]"), "{block}");
1920    }
1921
1922    /// Alternatives are listed beside the suggestion, and a single choice is
1923    /// not padded out with a column that has nothing in it.
1924    /// A long list wraps into its column rather than running off the edge, so
1925    /// codex's eight effort levels beside a long model name stay readable.
1926    #[test]
1927    fn a_long_list_of_choices_wraps_into_its_column() {
1928        let block = agent_block(
1929            "codex",
1930            &spec(
1931                &[
1932                    "gpt-5.6-sol",
1933                    "gpt-5.6-terra",
1934                    "gpt-5.6-luna",
1935                    "gpt-5.6-pro",
1936                ],
1937                &[
1938                    "ultra", "max", "xhigh", "high", "medium", "low", "minimal", "none",
1939                ],
1940            ),
1941        );
1942        assert!(
1943            block.lines().all(|l| l.chars().count() <= 80),
1944            "a line runs off the edge:\n{block}"
1945        );
1946        // Every choice survives the wrapping.
1947        for choice in ["gpt-5.6-pro", "minimal", "none"] {
1948            assert!(block.contains(choice), "{choice} was lost:\n{block}");
1949        }
1950    }
1951
1952    #[test]
1953    fn alternatives_are_listed_only_when_there_are_any() {
1954        assert!(agent_block("a", &spec(&["one", "two"], &[])).contains("# one | two"));
1955        let single = agent_block("b", &spec(&["only"], &[]));
1956        assert!(!single.contains('|'), "{single}");
1957    }
1958}