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