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