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
1061const STYLE_OPTIONS: &[Setting] = &[
1062    (false, "ban_em_dash", ""),
1063    (false, "ban_ai_attribution", ""),
1064    (
1065        false,
1066        "terse",
1067        "hold model prose to a length budget. false removes the valves entirely",
1068    ),
1069    (
1070        true,
1071        "pr_comments",
1072        "outcome | rounds | none. How much of its own working spar narrates into a PR thread. none never comments at all.",
1073    ),
1074    (
1075        true,
1076        "max_title_chars",
1077        "a finding, issue, or PR title. Never ellipsised",
1078    ),
1079    (
1080        true,
1081        "max_summary_chars",
1082        "a one line verdict or refutation",
1083    ),
1084    (
1085        true,
1086        "max_detail_chars",
1087        "a blocking finding, in the PR thread",
1088    ),
1089    (true, "max_body_chars", "a PR body"),
1090    (
1091        true,
1092        "max_issue_body_chars",
1093        "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.",
1094    ),
1095];
1096
1097/// The `[loop]` and `[style]` blocks of a generated config.
1098///
1099/// Safety valves, not editors: the length budgets here are sized so real
1100/// content is never touched, which is why they read as large numbers.
1101fn settings_block(first_implementor: &str) -> String {
1102    let defaults: std::collections::BTreeMap<String, String> = config::known_options()
1103        .into_iter()
1104        .map(|option| (option.key, option.default))
1105        .collect();
1106    // first_implementor has no default: it is whichever agent was written
1107    // first, and until there is a config there is no answer to give.
1108    let value = |key: &str| match key {
1109        "first_implementor" => format!("\"{first_implementor}\""),
1110        other => defaults.get(other).cloned().unwrap_or_default(),
1111    };
1112
1113    let mut out = String::from("[loop]\n");
1114    out.push_str(&option_lines(LOOP_OPTIONS, &value));
1115    out.push_str(concat!(
1116        "\n[loop.effort_schedule]\n",
1117        "# Values are whatever each agent's own CLI accepts, listed above, so\n",
1118        "# these are examples rather than defaults. Left out, each agent uses\n",
1119        "# the effort its own block asked for.\n",
1120        "# round_1 = \"high\"   # the deep first review\n",
1121        "# rest    = \"low\"    # later rounds only see a small delta\n\n",
1122    ));
1123    out.push_str("[style]\n");
1124    out.push_str(&option_lines(STYLE_OPTIONS, &value));
1125    out
1126}
1127
1128/// Option lines with their notes lined up in a column, a long note wrapping
1129/// onto continuation lines that stay in the column rather than running off the
1130/// edge or restarting at the margin.
1131fn option_lines(options: &[Setting], value: &dyn Fn(&str) -> String) -> String {
1132    const WIDTH: usize = 78;
1133
1134    let assignments: Vec<String> = options
1135        .iter()
1136        .map(|(commented, key, _)| {
1137            let lead = if *commented { "# " } else { "" };
1138            format!("{lead}{key} = {}", value(key))
1139        })
1140        .collect();
1141    let column = assignments
1142        .iter()
1143        .map(|a| a.chars().count())
1144        .max()
1145        .unwrap_or(0)
1146        + 2;
1147
1148    let mut out = String::new();
1149    for (assignment, (_, _, note)) in assignments.iter().zip(options) {
1150        if note.is_empty() {
1151            out.push_str(assignment);
1152            out.push('\n');
1153            continue;
1154        }
1155        let mut first = true;
1156        let mut line = String::new();
1157        for word in note.split_whitespace() {
1158            let would_be = column + 2 + line.chars().count() + 1 + word.chars().count();
1159            if !line.is_empty() && would_be > WIDTH {
1160                out.push_str(&noted(assignment, &line, column, &mut first));
1161                line.clear();
1162            }
1163            if !line.is_empty() {
1164                line.push(' ');
1165            }
1166            line.push_str(word);
1167        }
1168        if !line.is_empty() {
1169            out.push_str(&noted(assignment, &line, column, &mut first));
1170        }
1171    }
1172    out
1173}
1174
1175/// One rendered line: the assignment and the start of its note, then the rest
1176/// of the note alone, indented to the same column so it reads as one paragraph.
1177fn noted(assignment: &str, note: &str, column: usize, first: &mut bool) -> String {
1178    let lead = if *first {
1179        let pad = column.saturating_sub(assignment.chars().count());
1180        format!("{assignment}{}", " ".repeat(pad))
1181    } else {
1182        " ".repeat(column)
1183    };
1184    *first = false;
1185    format!("{lead}# {note}\n")
1186}
1187
1188/// One prerequisite check: a label and something that either reports a version
1189/// or explains what is missing.
1190type Probe = Box<dyn Fn() -> Result<String>>;
1191
1192/// One agent's block, with the options commented out beside a working value.
1193///
1194/// The values come from the preset rather than from here, so a CLI that adds a
1195/// model is a file edit. They are hints only: nothing validates against them,
1196/// because a stale list that refused a model which actually works would be
1197/// worse than no hint at all.
1198fn agent_block(name: &str, spec: &config::AgentSpec) -> String {
1199    let mut out = format!("[agents.{name}]\npreset = \"{name}\"\n");
1200    out.push_str("# Omit model or effort to use the CLI's own default.\n");
1201
1202    // The first entry of each list is the one written as the suggested value,
1203    // which is why the presets put the sensible default there rather than in
1204    // whatever order a CLI's help happens to print.
1205    fn suggested(choices: &[String]) -> &str {
1206        choices.first().map(String::as_str).unwrap_or("...")
1207    }
1208    let assignments = [
1209        format!("# model  = \"{}\"", suggested(&spec.models)),
1210        format!("# effort = \"{}\"", suggested(&spec.efforts)),
1211    ];
1212    // Line the comments up, so the file reads as a column rather than a jumble.
1213    let column = assignments
1214        .iter()
1215        .map(|a| a.chars().count())
1216        .max()
1217        .unwrap_or(0)
1218        + 3;
1219    for (assignment, choices) in assignments.iter().zip([&spec.models, &spec.efforts]) {
1220        out.push_str(assignment);
1221        if choices.len() > 1 {
1222            let pad = column.saturating_sub(assignment.chars().count());
1223            out.push_str(&" ".repeat(pad));
1224            out.push_str(&format!("# {}", choices.join(" | ")));
1225        }
1226        out.push('\n');
1227    }
1228
1229    if let Some(note) = &spec.options_note {
1230        out.push_str(&wrap_comment(note));
1231    }
1232    // Anything but this agent's own preset: a CLI that has just refused is not
1233    // a stand in for itself.
1234    let backup = if name == "cursor" { "gemini" } else { "cursor" };
1235    out.push_str("# A stand in for when this CLI refuses, stalls, or runs out of quota.\n");
1236    out.push_str("# It answers in place of this agent, never alongside it.\n");
1237    out.push_str(&format!(
1238        "# [agents.{name}.fallback]\n# preset = \"{backup}\"\n"
1239    ));
1240    out.push('\n');
1241    out
1242}
1243
1244/// Wrap a note across comment lines so a long one does not run off the edge.
1245fn wrap_comment(text: &str) -> String {
1246    const WIDTH: usize = 76;
1247    let mut out = String::new();
1248    let mut line = String::from("#");
1249    for word in text.split_whitespace() {
1250        if line.chars().count() + 1 + word.chars().count() > WIDTH && line.len() > 1 {
1251            out.push_str(&line);
1252            out.push('\n');
1253            line = String::from("#");
1254        }
1255        line.push(' ');
1256        line.push_str(word);
1257    }
1258    if line.len() > 1 {
1259        out.push_str(&line);
1260        out.push('\n');
1261    }
1262    out
1263}
1264
1265fn cmd_doctor(config_path: Option<&Path>) -> Result<i32> {
1266    let mut ok = true;
1267
1268    let probes: Vec<(&str, Probe)> = vec![
1269        (
1270            "git",
1271            Box::new(|| {
1272                proc::run_str(&["git", "--version"], &ExecOpts::new().timeout_secs(30))
1273                    .map(|s| first_line(&s))
1274            }),
1275        ),
1276        (
1277            "gh",
1278            Box::new(|| {
1279                proc::run_str(&["gh", "--version"], &ExecOpts::new().timeout_secs(30))
1280                    .map(|s| first_line(&s))
1281            }),
1282        ),
1283        (
1284            "gh auth",
1285            Box::new(|| {
1286                let out = proc::exec(
1287                    &["gh".into(), "auth".into(), "status".into()],
1288                    &ExecOpts::new().check(false).timeout_secs(60),
1289                )?;
1290                let text = format!("{}\n{}", out.stderr.trim(), out.stdout.trim());
1291                if out.ok() {
1292                    Ok(first_line(&text))
1293                } else {
1294                    Err(spar_err!("not authenticated. Run `gh auth login`."))
1295                }
1296            }),
1297        ),
1298    ];
1299
1300    for (label, probe) in probes {
1301        match probe() {
1302            Ok(detail) => println!("  ok    {label:12} {detail}"),
1303            Err(e) => {
1304                println!("  FAIL  {label:12} {}", e.first_line());
1305                ok = false;
1306            }
1307        }
1308    }
1309
1310    let found = config::find_config(config_path)?;
1311    let Some(path) = found else {
1312        println!("\n  no spar.toml found. Run `spar init` to generate one.");
1313        println!(
1314            "  presets available: {}",
1315            config::available_presets().join(", ")
1316        );
1317        return Ok(if ok { 0 } else { 1 });
1318    };
1319
1320    println!("\n  config: {}", path.display());
1321    let cfg = match config::load(Some(&path)) {
1322        Ok(cfg) => cfg,
1323        Err(e) => {
1324            println!("  FAIL  config       {e}");
1325            return Ok(1);
1326        }
1327    };
1328
1329    // Kept apart from `ok`: a missing gh says nothing about whether the two
1330    // agents are the same CLI, and must not silence the warning below.
1331    let mut resolved = Vec::new();
1332    for spec in &cfg.agents {
1333        let agent = Agent::new(spec.clone());
1334        match agent.resolve_bin() {
1335            Ok(bin) => {
1336                println!(
1337                    "  ok    {:12} {}  ({})",
1338                    spec.name,
1339                    bin.display(),
1340                    spec.describe()
1341                );
1342                report_fallback(&agent);
1343                resolved.push(agent);
1344            }
1345            Err(e) => {
1346                println!("  FAIL  {:12} {}", spec.name, e.first_line());
1347                ok = false;
1348            }
1349        }
1350    }
1351
1352    if resolved.len() == cfg.agents.len() {
1353        if let Some(warning) = agent::correlation_warning(&resolved) {
1354            println!("\n  WARNING  {warning}");
1355        }
1356    }
1357
1358    println!(
1359        "\n  settings: max_rounds={} auto_merge={} worktrees={} followups={} terse={}",
1360        cfg.loop_cfg.max_rounds,
1361        cfg.loop_cfg.auto_merge,
1362        cfg.loop_cfg.worktrees,
1363        cfg.loop_cfg.followups,
1364        cfg.style.terse
1365    );
1366    // What somebody upgrading wants to know. `spar init` refuses to touch an
1367    // existing config, so without this there is no way to learn that a release
1368    // added a setting short of reading the source.
1369    if let Ok(text) = std::fs::read_to_string(&path) {
1370        let unset = config::unmentioned_options(&text);
1371        if !unset.is_empty() {
1372            println!(
1373                "\n  {} setting(s) this config does not mention, all at their defaults:",
1374                unset.len()
1375            );
1376            for option in &unset {
1377                println!(
1378                    "      [{}] {} = {}",
1379                    option.section, option.key, option.default
1380                );
1381            }
1382            println!(
1383                "  `spar init --update {}` appends them as comments.",
1384                path.display()
1385            );
1386        }
1387    }
1388
1389    println!(
1390        "{}",
1391        if ok {
1392            "\nready"
1393        } else {
1394            "\nmissing prerequisites"
1395        }
1396    );
1397    Ok(if ok { 0 } else { 1 })
1398}
1399
1400fn first_line(text: &str) -> String {
1401    text.trim().lines().next().unwrap_or("").trim().to_string()
1402}
1403
1404// ---------------------------------------------------------------------------
1405// Reporting
1406// ---------------------------------------------------------------------------
1407
1408fn report(results: &[IssueRun], cfg: &Config) -> i32 {
1409    println!("\n{}", "=".repeat(60));
1410    for r in results {
1411        println!(
1412            "#{:<5} {:<10} rounds={} {}",
1413            r.issue,
1414            r.status.to_string(),
1415            r.rounds,
1416            r.pr.as_deref().unwrap_or("")
1417        );
1418        for note in &r.notes {
1419            println!("       {}", first_line(note));
1420        }
1421        for url in &r.filed {
1422            println!("       filed {url}");
1423        }
1424        for dispute in &r.disputes {
1425            println!("       disputed: {}", dispute.title);
1426        }
1427    }
1428    println!("{}", "=".repeat(60));
1429
1430    if !cfg.loop_cfg.auto_merge && results.iter().any(|r| r.status == Status::Approved) {
1431        println!("\nApproved PRs are waiting on you to merge.");
1432    }
1433    let recorded: usize = results.iter().map(|r| r.filed.len()).sum();
1434    if recorded > 0 && cfg.loop_cfg.followups == crate::config::Followups::Local {
1435        println!(
1436            "\n{recorded} follow-up(s) recorded in .spar/followups.md, not on the tracker. \
1437             Set followups = \"issues\" to file them."
1438        );
1439    }
1440    if results.iter().all(IssueRun::succeeded) {
1441        0
1442    } else {
1443        1
1444    }
1445}
1446
1447#[cfg(test)]
1448mod tests {
1449    use super::*;
1450    use clap::CommandFactory;
1451
1452    #[test]
1453    fn the_parser_is_internally_consistent() {
1454        Cli::command().debug_assert();
1455    }
1456
1457    #[test]
1458    fn quiet_is_accepted_before_or_after_the_subcommand() {
1459        for argv in [
1460            vec!["spar", "--quiet", "run", "42"],
1461            vec!["spar", "run", "42", "--quiet"],
1462            vec!["spar", "resume", "--quiet"],
1463            vec!["spar", "init", "-q"],
1464        ] {
1465            assert!(Cli::parse_from(&argv).quiet, "{argv:?}");
1466        }
1467        assert!(!Cli::parse_from(["spar", "run", "42"]).quiet);
1468    }
1469
1470    #[test]
1471    fn several_issue_numbers_are_accepted() {
1472        let cli = Cli::parse_from(["spar", "run", "42", "51", "60"]);
1473        match cli.command {
1474            Command::Run { issues, .. } => assert_eq!(vec![42, 51, 60], issues),
1475            other => panic!("{other:?}"),
1476        }
1477    }
1478
1479    #[test]
1480    fn issue_numbers_and_flags_can_be_interleaved() {
1481        let cli = Cli::parse_from(["spar", "run", "42", "--auto-merge", "51"]);
1482        match cli.command {
1483            Command::Run {
1484                issues, loop_flags, ..
1485            } => {
1486                assert_eq!(vec![42, 51], issues);
1487                assert!(loop_flags.auto_merge);
1488            }
1489            other => panic!("{other:?}"),
1490        }
1491    }
1492
1493    #[test]
1494    fn every_command_that_reads_a_config_accepts_one() {
1495        for argv in [
1496            vec!["spar", "run", "42"],
1497            vec!["spar", "triage"],
1498            vec!["spar", "resume"],
1499            vec!["spar", "clean"],
1500            vec!["spar", "doctor"],
1501        ] {
1502            let mut full = argv.clone();
1503            full.extend(["--config", "other.toml"]);
1504            let cli = Cli::parse_from(&full);
1505            let config = match cli.command {
1506                Command::Run { common, .. }
1507                | Command::Triage { common, .. }
1508                | Command::Resume { common, .. } => common.config,
1509                Command::Clean { config, .. } | Command::Doctor { config } => config,
1510                other => panic!("{other:?}"),
1511            };
1512            assert_eq!(Some(PathBuf::from("other.toml")), config, "{argv:?}");
1513        }
1514    }
1515
1516    #[test]
1517    fn auto_merge_is_off_unless_asked_for() {
1518        let cli = Cli::parse_from(["spar", "run"]);
1519        match cli.command {
1520            Command::Run { loop_flags, .. } => assert!(!loop_flags.auto_merge),
1521            other => panic!("{other:?}"),
1522        }
1523    }
1524
1525    #[test]
1526    fn the_two_close_skipped_flags_are_mutually_exclusive() {
1527        assert!(
1528            Cli::try_parse_from(["spar", "run", "--close-skipped", "--no-close-skipped"]).is_err()
1529        );
1530    }
1531
1532    /// Only `run` triages, so only `run` can decline an issue. Accepting the
1533    /// flag on `resume` would silently do nothing.
1534    #[test]
1535    fn close_skipped_is_offered_only_where_it_means_something() {
1536        assert!(Cli::try_parse_from(["spar", "run", "--close-skipped"]).is_ok());
1537        assert!(Cli::try_parse_from(["spar", "run", "--no-close-skipped"]).is_ok());
1538        assert!(Cli::try_parse_from(["spar", "resume", "--close-skipped"]).is_err());
1539        assert!(Cli::try_parse_from(["spar", "review", "--close-skipped"]).is_err());
1540        assert!(Cli::try_parse_from(["spar", "triage", "--close-skipped"]).is_err());
1541    }
1542
1543    #[test]
1544    fn the_close_skipped_pair_resolves_to_a_tristate() {
1545        let read = |argv: &[&str]| match Cli::parse_from(argv).command {
1546            Command::Run { triage_flags, .. } => {
1547                match (triage_flags.close_skipped, triage_flags.no_close_skipped) {
1548                    (true, _) => Some(true),
1549                    (_, true) => Some(false),
1550                    _ => None,
1551                }
1552            }
1553            other => panic!("{other:?}"),
1554        };
1555        assert_eq!(None, read(&["spar", "run"]));
1556        assert_eq!(Some(true), read(&["spar", "run", "--close-skipped"]));
1557        assert_eq!(Some(false), read(&["spar", "run", "--no-close-skipped"]));
1558    }
1559
1560    #[test]
1561    fn the_default_limit_is_twenty() {
1562        let cli = Cli::parse_from(["spar", "run"]);
1563        match cli.command {
1564            Command::Run { common, .. } => assert_eq!(20, common.limit),
1565            other => panic!("{other:?}"),
1566        }
1567    }
1568
1569    #[test]
1570    fn the_scrub_filter_subcommand_is_hidden_but_reachable() {
1571        assert!(matches!(
1572            Cli::parse_from(["spar", "scrub-filter"]).command,
1573            Command::ScrubFilter
1574        ));
1575        let help = Cli::command().render_long_help().to_string();
1576        assert!(
1577            !help.contains("scrub-filter"),
1578            "it is plumbing, not a command"
1579        );
1580    }
1581
1582    #[test]
1583    fn review_takes_pr_numbers_and_a_dry_run() {
1584        let cli = Cli::parse_from(["spar", "review", "101", "102", "--dry-run"]);
1585        match cli.command {
1586            Command::Review { items, dry_run, .. } => {
1587                assert_eq!(vec![101, 102], items);
1588                assert!(dry_run);
1589            }
1590            other => panic!("{other:?}"),
1591        }
1592    }
1593
1594    #[test]
1595    fn review_posts_unless_told_not_to() {
1596        match Cli::parse_from(["spar", "review", "101"]).command {
1597            Command::Review { dry_run, .. } => assert!(!dry_run),
1598            other => panic!("{other:?}"),
1599        }
1600    }
1601
1602    #[test]
1603    fn review_with_no_numbers_is_allowed() {
1604        match Cli::parse_from(["spar", "review"]).command {
1605            Command::Review { items, .. } => assert!(items.is_empty()),
1606            other => panic!("{other:?}"),
1607        }
1608    }
1609
1610    #[test]
1611    fn review_takes_its_own_round_budget() {
1612        match Cli::parse_from(["spar", "review", "101", "--max-rounds", "2"]).command {
1613            Command::Review { max_rounds, .. } => assert_eq!(Some(2), max_rounds),
1614            other => panic!("{other:?}"),
1615        }
1616    }
1617
1618    #[test]
1619    fn resume_takes_a_next_override() {
1620        let cli = Cli::parse_from(["spar", "resume", "108", "--next", "codex"]);
1621        match cli.command {
1622            Command::Resume {
1623                prs, next_actor, ..
1624            } => {
1625                assert_eq!(vec![108], prs);
1626                assert_eq!(Some("codex".to_string()), next_actor);
1627            }
1628            other => panic!("{other:?}"),
1629        }
1630    }
1631}
1632
1633#[cfg(test)]
1634mod absorb_tests {
1635    use super::*;
1636
1637    #[test]
1638    fn absorb_is_off_unless_asked_for() {
1639        match Cli::parse_from(["spar", "run"]).command {
1640            Command::Run { loop_flags, .. } => assert_eq!(None, loop_flags.absorb),
1641            other => panic!("{other:?}"),
1642        }
1643    }
1644
1645    #[test]
1646    fn absorb_takes_a_wave_count() {
1647        match Cli::parse_from(["spar", "run", "--absorb", "2"]).command {
1648            Command::Run { loop_flags, .. } => assert_eq!(Some(2), loop_flags.absorb),
1649            other => panic!("{other:?}"),
1650        }
1651    }
1652
1653    #[test]
1654    fn absorb_is_only_offered_where_issues_are_worked() {
1655        assert!(Cli::try_parse_from(["spar", "run", "--absorb", "1"]).is_ok());
1656        assert!(Cli::try_parse_from(["spar", "resume", "--absorb", "1"]).is_ok());
1657        assert!(Cli::try_parse_from(["spar", "review", "--absorb", "1"]).is_err());
1658    }
1659}
1660
1661#[cfg(test)]
1662mod min_number_tests {
1663    use super::*;
1664
1665    fn read(argv: &[&str]) -> Option<i64> {
1666        match Cli::parse_from(argv).command {
1667            Command::Run { common, .. }
1668            | Command::Triage { common, .. }
1669            | Command::Resume { common, .. }
1670            | Command::Review { common, .. } => common.min_number,
1671            other => panic!("{other:?}"),
1672        }
1673    }
1674
1675    #[test]
1676    fn there_is_no_floor_unless_one_is_asked_for() {
1677        assert_eq!(None, read(&["spar", "run"]));
1678    }
1679
1680    #[test]
1681    fn every_command_that_picks_for_itself_accepts_a_floor() {
1682        for cmd in ["run", "triage", "resume", "review"] {
1683            assert_eq!(
1684                Some(480),
1685                read(&["spar", cmd, "--min-number", "480"]),
1686                "{cmd}"
1687            );
1688        }
1689    }
1690}
1691
1692#[cfg(test)]
1693mod settings_block_tests {
1694    use super::*;
1695
1696    /// The value a config line offers, with its trailing note removed. Quote
1697    /// aware, since a note is free to contain a `#` and several do.
1698    fn written(line: &str) -> String {
1699        let after = line.split_once('=').expect("an assignment").1;
1700        let mut quoted = false;
1701        for (i, c) in after.char_indices() {
1702            match c {
1703                '"' => quoted = !quoted,
1704                '#' if !quoted => return after[..i].trim().to_string(),
1705                _ => {}
1706            }
1707        }
1708        after.trim().to_string()
1709    }
1710
1711    fn line_for(text: &str, key: &str) -> String {
1712        text.lines()
1713            .find(|l| {
1714                let bare = l.trim_start().trim_start_matches('#').trim_start();
1715                bare.starts_with(&format!("{key} ")) || bare.starts_with(&format!("{key}="))
1716            })
1717            .unwrap_or_else(|| panic!("{key} is not offered at all:\n{text}"))
1718            .to_string()
1719    }
1720
1721    /// The guard that was missing. Every number in the generated config used to
1722    /// be typed in beside its comment, which is a second copy of a default that
1723    /// lives in the code, and the copies stopped agreeing: it offered a title
1724    /// budget of 90 against a real 140, a body of 900 against 8000, and three
1725    /// more like it. Uncommenting one to see what it did cut every comment spar
1726    /// posts to a fifth of its length.
1727    #[test]
1728    fn every_value_it_offers_is_the_default_it_actually_has() {
1729        let text = settings_block("claude");
1730        for option in config::known_options() {
1731            // The effort words are per CLI, so the schedule's are examples of
1732            // what one accepts rather than defaults. There is no default
1733            // effort: an agent that names none uses its own CLI's.
1734            if option.section == "loop.effort_schedule" {
1735                continue;
1736            }
1737            let line = line_for(&text, &option.key);
1738            assert_eq!(
1739                option.default,
1740                written(&line),
1741                "the generated config offers `{}`, but the default is {}",
1742                line.trim(),
1743                option.default
1744            );
1745        }
1746    }
1747
1748    /// `doctor` reports what a config does not mention, so a generated one
1749    /// should send nobody to that list on the day it was written. pr_comments
1750    /// was missing from it for exactly that long.
1751    #[test]
1752    fn it_offers_every_option_the_parser_knows_about() {
1753        let text = settings_block("claude");
1754        let missing: Vec<String> = config::unmentioned_options(&text)
1755            .into_iter()
1756            .map(|o| format!("[{}] {}", o.section, o.key))
1757            .collect();
1758        assert!(missing.is_empty(), "not offered: {}", missing.join(", "));
1759    }
1760
1761    /// The strongest of these: every line the file suggests has to be a line
1762    /// that works. A commented option is an invitation to uncomment it, and one
1763    /// that then fails to load is worse than never having offered it.
1764    #[test]
1765    fn every_option_it_offers_can_be_uncommented_and_still_load() {
1766        let mut text = String::from(
1767            "[agents.claude]\ncommand = [\"claude\"]\n\n\
1768             [agents.codex]\ncommand = [\"codex\"]\n\n",
1769        );
1770        for line in settings_block("claude").lines() {
1771            text.push_str(uncomment(line).unwrap_or(line));
1772            text.push('\n');
1773        }
1774        let cfg = config::parse(&text).expect("a config of its own suggestions");
1775        assert_eq!("claude", cfg.first_implementor);
1776    }
1777
1778    /// A commented assignment with its `#` removed, or None for a line of
1779    /// prose, which stays a comment.
1780    fn uncomment(line: &str) -> Option<&str> {
1781        let bare = line.trim_start().strip_prefix('#')?.trim_start();
1782        // An assignment, not a wrapped note that happens to contain an `=`:
1783        // the key has to be one bare word.
1784        let key = bare.split_once('=')?.0.trim();
1785        let named = !key.is_empty()
1786            && key
1787                .chars()
1788                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
1789        named.then_some(bare)
1790    }
1791
1792    #[test]
1793    fn the_agent_that_goes_first_is_the_one_that_was_chosen() {
1794        assert!(settings_block("codex").contains("first_implementor = \"codex\""));
1795    }
1796
1797    /// A note long enough to wrap stays in its column rather than restarting at
1798    /// the margin, where it would read as a new option.
1799    #[test]
1800    fn a_wrapped_note_stays_in_its_column() {
1801        let text = settings_block("claude");
1802        let column = text
1803            .lines()
1804            .find(|l| l.starts_with("max_rounds"))
1805            .and_then(|l| l.find('#'))
1806            .expect("a note on max_rounds");
1807        let continuation = text
1808            .lines()
1809            .find(|l| l.starts_with("    ") && l.trim_start().starts_with('#'))
1810            .expect("a wrapped note");
1811        assert_eq!(Some(column), continuation.find('#'));
1812        assert!(text.lines().all(|l| l.chars().count() <= 80), "{text}");
1813    }
1814}