Skip to main content

spar/
cli.rs

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