Skip to main content

spar/
cli.rs

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