Skip to main content

speccoder_core/
step.rs

1//! How a fix line or a next step is taken from an interface: speccoder
2//! performs it, opens a view, runs the person's own command, or it belongs
3//! in the person's shell. One parser ([`step_of`]) for every line speccoder
4//! prints, so no interface copies a command speccoder can take itself.
5
6use serde::{Deserialize, Serialize};
7
8use crate::actions::{PlanFrom, Runnable, Through};
9
10/// A read-only place an interface opens instead of running a command.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12#[serde(rename_all = "snake_case")]
13pub enum View {
14    /// `doctor`: every check of the setup.
15    Doctor,
16    /// `doctor --bug-report`: the setup as a bug report, to copy.
17    BugReport,
18    /// `report`, `status`: every task and its evidence.
19    Report,
20    /// `report --live`, `tui`: the run as it goes.
21    Live,
22    /// `report --approvals`, `task approve|reject`: the calls waiting,
23    /// shown in full before a decision.
24    Approvals,
25    /// `explain CODE`: what a code means and how to resolve it.
26    Explain,
27    /// `review`: the spec's review.
28    Review,
29    /// `config`: the settings.
30    Settings,
31    /// `init`: the setup assistant.
32    Setup,
33    /// `bug`: the bug sheet.
34    Bug,
35    /// `do`: the quick-change sheet.
36    QuickChange,
37    /// `feature similar`: the new-feature sheet.
38    NewFeature,
39    /// `new`: the new-project sheet.
40    NewProject,
41    /// `auth`: the GitHub sign-in.
42    #[serde(rename = "github")]
43    GitHub,
44    /// A task's drawer (`subject`: its id).
45    Task,
46}
47
48/// How an interface takes a line.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
50#[serde(tag = "how", rename_all = "snake_case")]
51pub enum How {
52    /// speccoder performs it (`actions::perform`).
53    Perform {
54        /// What runs.
55        run: Box<Runnable>,
56        /// What to ask for first (`Why it is skipped`); `None`: nothing.
57        input: Option<String>,
58        /// The words the line gives already (a `--reason "…"` filled in).
59        text: Option<String>,
60        /// What to say in a confirmation first (it leaves the machine or
61        /// cannot be undone).
62        confirm: Option<String>,
63        /// The feature the line names (`--feature F`); `None`: the current.
64        feature: Option<String>,
65    },
66    /// A view to open.
67    View {
68        /// Which.
69        view: View,
70        /// What it is about: a code, a task.
71        subject: Option<String>,
72    },
73    /// A command speccoder printed as a fix and can run for the person, in
74    /// the project folder and the OS sandbox (see `outside`) — never one
75    /// composed elsewhere. Several argvs run in order, each only after the last
76    /// one succeeded (`a && b`).
77    Command {
78        /// The argvs, exactly.
79        argvs: Vec<Vec<String>>,
80        /// It reads the keyboard (a login): a terminal of the person's
81        /// own, not a panel.
82        interactive: bool,
83        /// What it changes outside the project, for a command that runs
84        /// outside the sandbox by its nature (`changes your global git
85        /// config`); `None`: it runs in the sandbox, writes confined to
86        /// the project and the package caches.
87        #[serde(default, skip_serializing_if = "Option::is_none")]
88        outside: Option<String>,
89        /// The host's proof that it issued exactly these argvs; only a
90        /// host that runs commands sets it.
91        #[serde(default, skip_serializing_if = "Option::is_none")]
92        token: Option<String>,
93    },
94    /// It must run in the shell speccoder starts from (`export NAME=…`):
95    /// nothing run elsewhere reaches that shell.
96    Shell {
97        /// Why it cannot run here.
98        why: String,
99        /// The setting that stands for it (`model`), if there is one.
100        setting: Option<String>,
101    },
102    /// Words for a person (`→ …`), or a command only they can complete.
103    Words {
104        /// Why it is not taken here; `None` for words.
105        why: Option<String>,
106    },
107}
108
109/// A line with how it is taken.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
111pub struct Step {
112    /// The line, as printed.
113    pub command: String,
114    /// How it is taken.
115    #[serde(flatten)]
116    pub how: How,
117}
118
119/// What a line is about when it does not say: the result's feature and task.
120#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
121pub struct Context<'a> {
122    /// The feature.
123    pub feature: Option<&'a str>,
124    /// The task.
125    pub task: Option<&'a str>,
126}
127
128/// The person's own programs a fix may name: anything else is words to
129/// copy (a line of prose is never run by accident).
130const PROGRAMS: &[&str] = &[
131    "aws",
132    "brew",
133    "bun",
134    "bundle",
135    "cargo",
136    "claude",
137    "codex",
138    "composer",
139    "copilot",
140    "corepack",
141    "deno",
142    "dotnet",
143    "gem",
144    "gemini",
145    "gh",
146    "git",
147    "go",
148    "gpg",
149    "gradle",
150    "just",
151    "make",
152    "mise",
153    "mkdir",
154    "mvn",
155    "npm",
156    "npx",
157    "opencode",
158    "openspec",
159    "pip",
160    "pip3",
161    "pipx",
162    "pnpm",
163    "poetry",
164    "python",
165    "python3",
166    "rustup",
167    "specify",
168    "ssh-keygen",
169    "uv",
170    "uvx",
171    "xcode-select",
172    "yarn",
173];
174
175/// The commands of speccoder no interface takes: developer tools.
176const DEVELOPER: &[&str] = &["eval", "replay", "bench", "completion", "acp", "workflow"];
177
178/// speccoder's subcommands: a line that starts `speccoder` and goes on
179/// with anything else is words (`speccoder did not create …`).
180pub const SUBCOMMANDS: &[&str] = &[
181    "acp",
182    "actions",
183    "approvals",
184    "attest",
185    "auth",
186    "bench",
187    "bug",
188    "completion",
189    "config",
190    "converge",
191    "do",
192    "doctor",
193    "eval",
194    "explain",
195    "feature",
196    "fix",
197    "help",
198    "inbox",
199    "init",
200    "inspect",
201    "issue",
202    "new",
203    "pr",
204    "preset",
205    "propose",
206    "replay",
207    "report",
208    "review",
209    "run",
210    "sample",
211    "schema",
212    "spec",
213    "status",
214    "suggest",
215    "task",
216    "tui",
217    "ui",
218    "verify",
219    "workflow",
220    "worktree",
221];
222
223/// The marker of text a person writes.
224const BLANK: &str = "…";
225
226/// Argvs run one after the other (`a && b`).
227pub type Argvs = Vec<Vec<String>>;
228
229/// How `line` is taken, with `ctx` filling what it leaves open.
230///
231/// A line is text, and text can carry values spliced into it (a file's
232/// name, a feature's): so a line becomes the person's own command
233/// ([`How::Command`]) only when it is one of speccoder's constant lines —
234/// a catalog fix or a builder's line, its `{task}`/`{feature}` slots
235/// filled with plain identifiers. A source that wants a command with
236/// other values gives its argvs ([`person`], [`steps_with`]). A speccoder
237/// command that speccoder performs or opens is read from any line: what it
238/// becomes is a typed runnable, never an argv.
239#[must_use]
240pub fn step_of(line: &str, ctx: Context<'_>) -> Step {
241    let filled = fill(line, ctx);
242    Step {
243        command: line.to_owned(),
244        how: how(&filled),
245    }
246}
247
248/// The person's own command a source built as argvs: shown as the line a
249/// shell would read ([`render`]), run exactly as given.
250#[must_use]
251pub fn person(argvs: Argvs) -> Step {
252    Step {
253        command: render(&argvs),
254        how: command(argvs),
255    }
256}
257
258/// Every line of `fix`, as steps; a line a source gave as argvs
259/// (`typed`, rendered to that line) is those argvs.
260#[must_use]
261pub fn steps_with(fix: &[String], typed: &[Argvs], ctx: Context<'_>) -> Vec<Step> {
262    fix.iter()
263        .map(|f| match typed.iter().find(|t| render(t) == *f) {
264            Some(t) => person(t.clone()),
265            None => step_of(f, ctx),
266        })
267        .collect()
268}
269
270/// Argvs as a shell reads them: a word with anything but plain characters
271/// single-quoted; `&&` between them.
272#[must_use]
273pub fn render(argvs: &[Vec<String>]) -> String {
274    let word = |w: &String| {
275        let plain = !w.is_empty()
276            && w.chars().all(|c| {
277                c.is_ascii_alphanumeric()
278                    || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | ',' | '@' | '+')
279            });
280        if plain {
281            w.clone()
282        } else {
283            format!("'{}'", w.replace('\'', "'\\''"))
284        }
285    };
286    argvs
287        .iter()
288        .map(|a| a.iter().map(word).collect::<Vec<_>>().join(" "))
289        .collect::<Vec<_>>()
290        .join(" && ")
291}
292
293fn fill(line: &str, ctx: Context<'_>) -> String {
294    let mut s = line.to_owned();
295    if let Some(t) = ctx.task {
296        s = s.replace("{task}", t).replace("<task>", t);
297    }
298    if let Some(f) = ctx.feature {
299        s = s.replace("{feature}", f).replace("<feature>", f);
300    }
301    s
302}
303
304fn words(why: Option<&str>) -> How {
305    How::Words {
306        why: why.map(str::to_owned),
307    }
308}
309
310/// Why a line with values of the project's is not run from here.
311const NAMES_VALUES: &str = "it names files or values of this project: run it in your shell";
312
313/// A trailing "(why)" is said, not run.
314fn said(line: &str) -> &str {
315    let line = line.trim();
316    match line.rfind(" (") {
317        Some(at) if line.ends_with(')') => line[..at].trim_end(),
318        _ => line,
319    }
320}
321
322fn how(line: &str) -> How {
323    let line = said(line);
324    if line.starts_with('→') || line.is_empty() {
325        return words(None);
326    }
327    if let Some(rest) = line.strip_prefix("export ") {
328        let name = rest.split('=').next().unwrap_or_default().trim();
329        let model = name.ends_with("_API_KEY") || name.starts_with("AWS_");
330        return How::Shell {
331            why: format!(
332                "`{name}` must be set where speccoder starts: a command run from here ends with its own shell, and the variable with it"
333            ),
334            setting: model.then(|| "model".to_owned()),
335        };
336    }
337    let Some(chain) = argvs(line) else {
338        return words(Some("a shell line: run it in your shell"));
339    };
340    let Some(first) = chain.first() else {
341        return words(None);
342    };
343    let constant = constant(&chain);
344    if first.first().map(String::as_str) == Some("speccoder") {
345        let known = first
346            .get(1)
347            .is_some_and(|w| SUBCOMMANDS.contains(&w.as_str()));
348        return if known && chain.len() == 1 {
349            speccoder(first, constant)
350        } else {
351            words(None)
352        };
353    }
354    if chain
355        .iter()
356        .any(|a| a.first().map(String::as_str) == Some("sudo"))
357    {
358        return words(Some("it asks for your password: run it in your shell"));
359    }
360    if chain.iter().any(|a| {
361        a.first()
362            .is_none_or(|p| p == "speccoder" || !PROGRAMS.contains(&p.as_str()))
363    }) {
364        return words(None);
365    }
366    if chain.iter().flatten().any(|w| has_blank(w)) {
367        return words(Some("it has a part only you can fill in"));
368    }
369    if constant {
370        command(chain)
371    } else {
372        words(Some(NAMES_VALUES))
373    }
374}
375
376/// Lines builders print that are no catalog fix: constant, or with
377/// `{task}`/`{feature}` slots.
378const BUILT: &[&str] = &[
379    "speccoder run --in-place",
380    "speccoder run --in-place --return",
381    "speccoder run --ignore-checklists",
382    "speccoder run --allow-failing-baseline",
383    "speccoder run --parallel 2",
384    "speccoder run --task {task} --fresh-acceptance",
385    "speccoder attest verify",
386    "speccoder attest verify --feature {feature} --json",
387    "speccoder feature --clear",
388    "speccoder explain --list",
389    "speccoder fix --dry-run",
390    "speccoder inspect",
391    "speccoder worktree",
392    "speccoder verify",
393    "speccoder schema",
394    "speccoder sample",
395    "speccoder spec archive",
396    "speccoder spec archive --yes",
397    "git stash -u",
398    "git status",
399    "speccoder spec archive {change} --yes",
400    "speccoder config commit",
401    "speccoder help",
402    "speccoder help advanced",
403    "speccoder sample --fresh --yes",
404    "speccoder spec",
405    "ssh-keygen -t ed25519",
406    "git config --global gpg.format ssh",
407];
408
409/// The constant lines, parsed: the catalog's fixes and [`BUILT`].
410fn templates() -> &'static [Argvs] {
411    static PARSED: std::sync::OnceLock<Vec<Argvs>> = std::sync::OnceLock::new();
412    PARSED.get_or_init(|| {
413        crate::codes::CATALOG
414            .iter()
415            .flat_map(|e| e.fix.iter())
416            // A line about another command (`speccoder {command} --help`)
417            // is said, never signed: its slot names any command.
418            .filter(|l| !l.contains("{command}"))
419            .chain(BUILT.iter())
420            .filter_map(|l| argvs(said(l)))
421            .collect()
422    })
423}
424
425/// A value a slot may take: a plain identifier (`001-x`, `T004`), never a
426/// flag, never more than one word.
427fn plain(v: &str) -> bool {
428    v.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
429        && v.chars()
430            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
431}
432
433/// `w` is the template word `t` with its slots filled by plain values.
434fn fills(t: &str, w: &str) -> bool {
435    let Some(open) = t.find('{') else {
436        return t == w;
437    };
438    let Some(close) = t[open..].find('}').map(|c| open + c) else {
439        return t == w;
440    };
441    let (head, rest) = (&t[..open], &t[close + 1..]);
442    let Some(w) = w.strip_prefix(head) else {
443        return false;
444    };
445    // The slot takes the shortest plain run after which the rest fits.
446    (1..=w.len())
447        .filter(|n| w.is_char_boundary(*n))
448        .any(|n| plain(&w[..n]) && fills(rest, &w[n..]))
449}
450
451/// `chain` is one of speccoder's constant lines (with `--feature F`
452/// appended, as next steps are).
453fn constant(chain: &[Vec<String>]) -> bool {
454    let matches = |t: &[String], a: &[String]| {
455        let a = match a {
456            [rest @ .., f, v]
457                if f == "--feature" && plain(v) && !t.iter().any(|w| w == "--feature") =>
458            {
459                rest
460            }
461            a => a,
462        };
463        t.len() == a.len() && t.iter().zip(a).all(|(t, w)| fills(t, w))
464    };
465    templates()
466        .iter()
467        .any(|t| t.len() == chain.len() && t.iter().zip(chain).all(|(t, a)| matches(t, a)))
468}
469
470fn has_blank(w: &str) -> bool {
471    w.contains(BLANK) || (w.contains('<') && w.contains('>')) || w.contains('{')
472}
473
474type Signer = std::rc::Rc<dyn Fn(&[Vec<String>], bool) -> String>;
475
476thread_local! {
477    static SIGNER: std::cell::RefCell<Option<Signer>> = const { std::cell::RefCell::new(None) };
478}
479
480/// Runs `f` with `signer` signing every command step built meanwhile on
481/// this thread (its `token`): a host that runs commands signs only the
482/// steps speccoder itself builds, never JSON from elsewhere.
483pub fn signing<T>(signer: Signer, f: impl FnOnce() -> T) -> T {
484    struct Reset(Option<Signer>);
485    impl Drop for Reset {
486        fn drop(&mut self) {
487            let before = self.0.take();
488            SIGNER.with(|s| *s.borrow_mut() = before);
489        }
490    }
491    let before = SIGNER.with(|s| s.borrow_mut().replace(signer));
492    let _reset = Reset(before);
493    f()
494}
495
496fn command(argvs: Argvs) -> How {
497    let interactive = argvs.iter().any(|a| interactive(a));
498    let token = SIGNER.with(|s| s.borrow().as_ref().map(|f| f(&argvs, interactive)));
499    How::Command {
500        interactive,
501        outside: argvs.iter().find_map(|a| outside(a)),
502        argvs,
503        token,
504    }
505}
506
507/// What `argv` changes outside the project when it is one of the few
508/// fixes that, by their nature, cannot run confined to it: those run
509/// outside the sandbox, and say so. Every other runs in it.
510#[must_use]
511pub fn outside(argv: &[String]) -> Option<String> {
512    match argv
513        .iter()
514        .map(String::as_str)
515        .collect::<Vec<_>>()
516        .as_slice()
517    {
518        ["git", "config", "--global", ..] => Some("changes your global git config".to_owned()),
519        ["git", "worktree", "remove", path] => Some(format!(
520            "removes the worktree at {path}, outside the project"
521        )),
522        ["brew", "install", .., package] => {
523            Some(format!("installs {package} on this machine with Homebrew"))
524        }
525        ["npm", "install", "-g" | "--global", package] => {
526            Some(format!("installs {package} on this machine with npm"))
527        }
528        _ => None,
529    }
530}
531
532/// `argv` reads the keyboard — a sign-in's prompt or device flow, an
533/// editor, a question — so it needs a terminal of the person's own.
534#[must_use]
535pub fn interactive(argv: &[String]) -> bool {
536    let w: Vec<&str> = argv.iter().map(String::as_str).collect();
537    let has = |f: &[&str]| {
538        w.iter()
539            .any(|x| f.contains(x) || f.iter().any(|p| p.ends_with('=') && x.starts_with(p)))
540    };
541    match w.as_slice() {
542        ["copilot", "login", ..]
543        | ["opencode", "auth", "login", ..]
544        | ["gh", "auth", "login" | "refresh", ..]
545        | ["aws", "login" | "configure", ..]
546        | ["aws", "sso", "login", ..]
547        | ["claude", ..]
548        | ["codex", "login", ..]
549        | ["codex"]
550        | ["gemini", ..]
551        | ["npm", "login" | "adduser", ..]
552        | ["specify", "init", ..]
553        | ["openspec", "init", ..]
554        | [
555            "gpg",
556            "--full-generate-key" | "--gen-key" | "--edit-key",
557            ..,
558        ]
559        | ["speccoder", "tui" | "ui", ..]
560        | ["speccoder", "auth", "login", ..] => true,
561        ["speccoder", "spec", "archive", ..] => !has(&["--yes"]),
562        ["ssh-keygen", ..] => !(has(&["-N"]) && has(&["-f"])),
563        ["git", "commit", ..] => !has(&["-m", "-F", "--file", "--no-edit", "-C", "--message="]),
564        ["git", "merge", ..] => !has(&["--no-edit", "--ff-only", "--abort"]),
565        ["git", "rebase", ..] => has(&["-i", "--interactive"]),
566        ["git", "add", ..] => has(&["-p", "-i", "--patch", "--interactive"]),
567        _ => false,
568    }
569}
570
571/// The words of a line, split as a shell would for the plain cases;
572/// `None` for anything a shell would expand or redirect. `a && b` is two.
573fn argvs(line: &str) -> Option<Vec<Vec<String>>> {
574    let mut out = vec![Vec::new()];
575    let mut word = String::new();
576    let mut in_word = false;
577    let mut quote: Option<char> = None;
578    let mut chars = line.chars().peekable();
579    while let Some(c) = chars.next() {
580        match (quote, c) {
581            (Some(q), c) if c == q => quote = None,
582            (Some('"'), '\\') => word.push(chars.next()?),
583            (Some('"'), '$' | '`') => return None,
584            (Some(_), c) => word.push(c),
585            (None, '"' | '\'') => {
586                quote = Some(c);
587                in_word = true;
588            }
589            // `&&` separates only as a word of its own (`a && b`).
590            (None, '&') if !in_word && chars.peek() == Some(&'&') => {
591                chars.next();
592                if !chars.peek().is_none_or(|c| c.is_whitespace()) {
593                    return None;
594                }
595                out.push(Vec::new());
596            }
597            // A comment ends the line.
598            (None, '#') if !in_word => break,
599            // `<name>` is a blank to fill in, never a redirection.
600            (None, '<') => {
601                word.push('<');
602                loop {
603                    let c = chars.next()?;
604                    word.push(c);
605                    if c == '>' {
606                        break;
607                    }
608                }
609                in_word = true;
610            }
611            (None, '|' | ';' | '>' | '$' | '`' | '&' | '(' | ')' | '*' | '~') => return None,
612            (None, c) if c.is_whitespace() => {
613                if in_word {
614                    out.last_mut()?.push(std::mem::take(&mut word));
615                    in_word = false;
616                }
617            }
618            (None, '\\') => return None,
619            (None, c) => {
620                word.push(c);
621                in_word = true;
622            }
623        }
624    }
625    if quote.is_some() {
626        return None;
627    }
628    if in_word {
629        out.last_mut()?.push(word);
630    }
631    (!out.iter().any(Vec::is_empty)).then_some(out)
632}
633
634/// The value after `flag` (`--feature F` → `F`).
635fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
636    args.iter()
637        .position(|a| a == name)
638        .and_then(|i| args.get(i + 1))
639        .map(String::as_str)
640}
641
642fn has(args: &[String], name: &str) -> bool {
643    args.iter().any(|a| a == name)
644}
645
646/// The first word after the subcommand's words that is no flag.
647fn positional(args: &[String], skip: usize) -> Option<&str> {
648    let mut i = skip;
649    while let Some(a) = args.get(i) {
650        if a.starts_with('-') {
651            // A flag with a value: the flags speccoder's fixes use.
652            let valued = matches!(
653                a.as_str(),
654                "--feature"
655                    | "--reason"
656                    | "--note"
657                    | "--story"
658                    | "--task"
659                    | "--remote"
660                    | "--through"
661            );
662            i += if valued { 2 } else { 1 };
663            continue;
664        }
665        return Some(a);
666    }
667    None
668}
669
670/// The words given for an input, or the prompt to ask with when the line
671/// leaves them blank (`"…"`).
672fn input(given: Option<&str>, prompt: &str) -> (Option<String>, Option<String>) {
673    match given {
674        Some(t) if !t.contains(BLANK) && !t.trim().is_empty() => (None, Some(t.to_owned())),
675        _ => (Some(prompt.to_owned()), None),
676    }
677}
678
679fn view(view: View, subject: Option<&str>) -> How {
680    How::View {
681        view,
682        subject: subject.map(str::to_owned),
683    }
684}
685
686/// What a line about a feature names, when it names one fully.
687fn named_feature(args: &[String]) -> Option<String> {
688    flag(args, "--feature")
689        .filter(|f| !has_blank(f))
690        .map(str::to_owned)
691}
692
693fn perform(run: Runnable, args: &[String]) -> How {
694    How::Perform {
695        run: Box::new(run),
696        input: None,
697        text: None,
698        confirm: None,
699        feature: named_feature(args),
700    }
701}
702
703fn asking(run: Runnable, args: &[String], given: Option<&str>, prompt: &str) -> How {
704    let (input, text) = input(given, prompt);
705    How::Perform {
706        run: Box::new(run),
707        input,
708        text,
709        confirm: None,
710        feature: named_feature(args),
711    }
712}
713
714fn confirmed(run: Runnable, args: &[String], confirm: &str) -> How {
715    How::Perform {
716        run: Box::new(run),
717        input: None,
718        text: None,
719        confirm: Some(confirm.to_owned()),
720        feature: named_feature(args),
721    }
722}
723
724/// The confirmation a pull request asks for (it leaves the machine).
725pub const PR_CONFIRM: &str = "Push the feature's speccoder branch and its attestation notes to `origin`, and open (or update) its draft pull request on GitHub, as you (your token).";
726
727/// A speccoder command line, as an interface takes it.
728#[allow(clippy::too_many_lines)] // one table, read top to bottom
729fn speccoder(argv: &[String], constant: bool) -> How {
730    // A feature not named yet is the one the interface has selected.
731    let mut args: Vec<String> = argv.get(1..).unwrap_or_default().to_vec();
732    if let Some(i) = args.iter().position(|a| a == "--feature")
733        && args.get(i + 1).is_some_and(|f| has_blank(f))
734    {
735        args.drain(i..=i + 1);
736    }
737    let a = args.as_slice();
738    let word = |i: usize| a.get(i).map(String::as_str);
739    let task = |i: usize| {
740        positional(a, i)
741            .filter(|t| !has_blank(t))
742            .map(str::to_owned)
743    };
744    // As written: only one of speccoder's constant lines.
745    let run_cmd = || {
746        if a.iter().any(|w| has_blank(w)) {
747            words(Some("it has a part only you can fill in"))
748        } else if constant {
749            command(vec![argv.to_vec()])
750        } else {
751            words(Some(NAMES_VALUES))
752        }
753    };
754    match (word(0), word(1)) {
755        (Some("doctor"), _) if has(a, "--bug-report") => view(View::BugReport, None),
756        (Some("doctor"), _) => view(View::Doctor, None),
757        (Some("status"), _) => view(View::Report, None),
758        (Some("report"), _) if has(a, "--approvals") => view(View::Approvals, None),
759        (Some("report"), _) if has(a, "--live") => view(View::Live, None),
760        (Some("report"), _) => view(View::Report, None),
761        (Some("tui"), _) => view(View::Live, None),
762        (Some("ui" | "inbox" | "approvals"), _) if !has(a, "-C") => view(View::Approvals, None),
763        (Some("explain"), Some(code)) if !code.starts_with('-') => view(View::Explain, Some(code)),
764        (Some("run"), _) if has(a, "--converge") && !a.iter().any(|w| w == "--task") => {
765            perform(Runnable::Converge, a)
766        }
767        (Some("converge"), _) => perform(Runnable::Converge, a),
768        (Some("run"), _) => {
769            // Only what a run from an interface does: one task or the
770            // feature, nothing else changed. Other flags run as written.
771            let mut rest = a.iter().skip(1);
772            let mut plain = true;
773            while let Some(w) = rest.next() {
774                match w.as_str() {
775                    "--task" | "--feature" | "--agent" => plain &= rest.next().is_some(),
776                    "-v" => {}
777                    _ => plain = false,
778                }
779            }
780            match flag(a, "--task") {
781                _ if !plain => run_cmd(),
782                Some(t) if has_blank(t) => view(View::Report, None),
783                Some(t) => perform(
784                    Runnable::Run {
785                        task: Some(t.to_owned()),
786                        agent: flag(a, "--agent").map(str::to_owned),
787                    },
788                    a,
789                ),
790                None => perform(
791                    Runnable::Run {
792                        task: None,
793                        agent: flag(a, "--agent").map(str::to_owned),
794                    },
795                    a,
796                ),
797            }
798        }
799        (Some("task"), Some("approve" | "reject")) => view(View::Approvals, None),
800        (Some("task"), Some(verb)) => {
801            let Some(t) = task(2) else {
802                return view(View::Report, None);
803            };
804            match verb {
805                "accept" => asking(
806                    Runnable::Accept { task: t },
807                    a,
808                    flag(a, "--reason"),
809                    "Why it is done",
810                ),
811                "skip" => asking(
812                    Runnable::Skip { task: t },
813                    a,
814                    flag(a, "--reason"),
815                    "Why it is skipped",
816                ),
817                "nudge" => asking(
818                    Runnable::Nudge { task: t },
819                    a,
820                    flag(a, "--note"),
821                    "The note",
822                ),
823                "hint" => asking(Runnable::Hint { task: t }, a, positional(a, 3), "The hint"),
824                "rule" => asking(
825                    Runnable::Rule { task: t },
826                    a,
827                    flag(a, "--reason"),
828                    "Why the tests are right",
829                ),
830                "done" => perform(Runnable::Done { task: t }, a),
831                "restore" => perform(Runnable::KeepSetAside { task: t }, a),
832                "drop" => confirmed(
833                    Runnable::DropSetAside { task: t.clone() },
834                    a,
835                    &format!(
836                        "Discard the changes set aside before {t}? They are deleted, not kept anywhere."
837                    ),
838                ),
839                _ => view(View::Task, Some(&t)),
840            }
841        }
842        (Some("review"), Some("approve")) if a.len() == 2 => perform(
843            Runnable::Review {
844                ids: Vec::new(),
845                verdict: "approve".to_owned(),
846            },
847            a,
848        ),
849        (Some("review"), Some("answer")) => match task(2) {
850            Some(q) => asking(
851                Runnable::Review {
852                    ids: vec![q],
853                    verdict: "answer".to_owned(),
854                },
855                a,
856                positional(a, 3),
857                "Your answer",
858            ),
859            None => view(View::Review, None),
860        },
861        (Some("review"), _) => view(View::Review, None),
862        // Another project's: run there.
863        (Some(_), _) if has(a, "-C") => words(Some("it is another project's: run it there")),
864        (Some("spec"), Some("archive")) => run_cmd(),
865        (Some("spec"), Some(name)) if !has_blank(name) && !name.starts_with('-') => {
866            let given = positional(a, 2);
867            if let Some(through) = flag(a, "--through").and_then(Through::parse) {
868                // Words to plan from: the new-feature sheet asks for them.
869                return match given {
870                    Some(_) => view(View::NewFeature, None),
871                    None => perform(
872                        Runnable::PlanThrough {
873                            from: PlanFrom::Continue,
874                            through,
875                        },
876                        a,
877                    ),
878                };
879            }
880            if a.iter()
881                .skip(2)
882                .any(|w| w.starts_with('-') && w != "--feature")
883            {
884                // A flag only the command takes.
885                return run_cmd();
886            }
887            let skill = |arguments: &str| Runnable::Skill {
888                name: name.to_owned(),
889                arguments: arguments.to_owned(),
890            };
891            match given {
892                None => perform(skill(""), a),
893                Some(w) if has_blank(w) => asking(skill(""), a, None, "Describe it"),
894                Some(w) => perform(skill(w), a),
895            }
896        }
897        (Some("bug"), Some("keep")) => match task(2) {
898            Some(t) => asking(
899                Runnable::BugKeep { task: t },
900                a,
901                flag(a, "--reason"),
902                "Why it is kept",
903            ),
904            None => view(View::Bug, None),
905        },
906        (Some("bug"), Some("run")) => match flag(a, "--task").filter(|t| !has_blank(t)) {
907            Some(t) => perform(
908                Runnable::BugRun {
909                    slug: None,
910                    task: Some(t.to_owned()),
911                },
912                a,
913            ),
914            None => view(View::Bug, None),
915        },
916        (Some("bug"), _) => view(View::Bug, None),
917        (Some("do"), _) => view(View::QuickChange, None),
918        (Some("pr"), _) if flag(a, "--remote").is_some() => {
919            words(Some("name the remote it pushes to"))
920        }
921        (Some("pr"), _) => confirmed(Runnable::PullRequest, a, PR_CONFIRM),
922        (Some("fix"), _) if a.len() == 1 => perform(Runnable::FixSetup, a),
923        (Some("preset"), w) if w != Some("add") => words(None),
924        (Some("preset"), _) => confirmed(
925            Runnable::PresetAdd { update: false },
926            a,
927            "Install the speccoder preset with the spec tool's own preset command? It writes the spec tool's preset directory and registry; no spec file changes.",
928        ),
929        (Some("feature"), Some("similar")) => view(View::NewFeature, None),
930        (Some("feature"), None) => asking(Runnable::Feature, a, None, "The feature"),
931        (Some("feature"), Some(name)) if !name.starts_with('-') => asking(
932            Runnable::Feature,
933            a,
934            Some(name).filter(|n| !has_blank(n)),
935            "The feature",
936        ),
937        (Some("init"), _) => view(View::Setup, None),
938        (Some("config"), None | Some("get" | "unset")) => view(View::Settings, None),
939        (Some("config"), Some("set")) if a.iter().any(|w| has_blank(w)) => {
940            view(View::Settings, None)
941        }
942        (Some("new"), _) => view(View::NewProject, None),
943        (Some("auth"), _) => view(View::GitHub, None),
944        (Some(dev), _) if DEVELOPER.contains(&dev) => {
945            words(Some("a developer tool: run it in your shell"))
946        }
947        _ => run_cmd(),
948    }
949}
950
951impl How {
952    /// It is a copy and nothing else: words, or a shell line.
953    #[must_use]
954    pub const fn copy_only(&self) -> bool {
955        matches!(self, Self::Words { .. } | Self::Shell { .. })
956    }
957}
958
959/// Every line of `fix`, as steps.
960#[must_use]
961pub fn steps(fix: &[String], ctx: Context<'_>) -> Vec<Step> {
962    steps_with(fix, &[], ctx)
963}
964
965#[cfg(test)]
966mod tests {
967    use super::*;
968
969    fn h(line: &str) -> How {
970        step_of(
971            line,
972            Context {
973                feature: Some("001-x"),
974                task: Some("T001"),
975            },
976        )
977        .how
978    }
979
980    fn run_of(line: &str) -> (Runnable, Option<String>, Option<String>) {
981        match h(line) {
982            How::Perform {
983                run, input, text, ..
984            } => (*run, input, text),
985            other => panic!("{line}: {other:?}"),
986        }
987    }
988
989    fn view_of(line: &str) -> (View, Option<String>) {
990        match h(line) {
991            How::View { view, subject } => (view, subject),
992            other => panic!("{line}: {other:?}"),
993        }
994    }
995
996    fn argvs_of(line: &str) -> (Vec<Vec<String>>, bool) {
997        match h(line) {
998            How::Command {
999                argvs, interactive, ..
1000            } => (argvs, interactive),
1001            other => panic!("{line}: {other:?}"),
1002        }
1003    }
1004
1005    fn s(v: &[&str]) -> Vec<String> {
1006        v.iter().map(|w| (*w).to_owned()).collect()
1007    }
1008
1009    #[test]
1010    fn read_only_commands_open_their_view() {
1011        assert_eq!(view_of("speccoder doctor").0, View::Doctor);
1012        assert_eq!(
1013            view_of("speccoder doctor --feature {feature}").0,
1014            View::Doctor
1015        );
1016        assert_eq!(view_of("speccoder doctor --bug-report").0, View::BugReport);
1017        assert_eq!(view_of("speccoder report").0, View::Report);
1018        assert_eq!(view_of("speccoder status").0, View::Report);
1019        assert_eq!(view_of("speccoder report --approvals").0, View::Approvals);
1020        assert_eq!(view_of("speccoder report --live").0, View::Live);
1021        assert_eq!(view_of("speccoder report --feature <name>").0, View::Report);
1022        assert_eq!(
1023            view_of("speccoder explain NEEDS-APPROVAL"),
1024            (View::Explain, Some("NEEDS-APPROVAL".to_owned()))
1025        );
1026        assert_eq!(view_of("speccoder task approve {task}").0, View::Approvals);
1027        assert_eq!(view_of("speccoder init --force").0, View::Setup);
1028        assert_eq!(view_of("speccoder config").0, View::Settings);
1029        assert_eq!(
1030            view_of("speccoder config set checks.test.argv -- <your test command>").0,
1031            View::Settings
1032        );
1033        assert_eq!(view_of("speccoder do \"…\"").0, View::QuickChange);
1034        assert_eq!(
1035            view_of("speccoder bug \"<what happens>\" --expected \"<what should>\"").0,
1036            View::Bug
1037        );
1038        assert_eq!(
1039            view_of("speccoder review tests <REQ> --write").0,
1040            View::Review
1041        );
1042        assert_eq!(
1043            view_of("speccoder task acceptance-at {task} --story <US>"),
1044            (View::Task, Some("T001".to_owned()))
1045        );
1046    }
1047
1048    #[test]
1049    fn speccoder_commands_with_a_runnable_perform() {
1050        let t = || "T001".to_owned();
1051        assert_eq!(
1052            run_of("speccoder run").0,
1053            Runnable::Run {
1054                task: None,
1055                agent: None
1056            }
1057        );
1058        assert_eq!(
1059            run_of("speccoder run --task T004 --agent opencode").0,
1060            Runnable::Run {
1061                task: Some("T004".to_owned()),
1062                agent: Some("opencode".to_owned())
1063            }
1064        );
1065        assert_eq!(
1066            run_of("speccoder run --task {task}").0,
1067            Runnable::Run {
1068                task: Some(t()),
1069                agent: None
1070            }
1071        );
1072        assert_eq!(
1073            run_of("speccoder run --feature {feature} -v").0,
1074            Runnable::Run {
1075                task: None,
1076                agent: None
1077            }
1078        );
1079        assert_eq!(run_of("speccoder run --converge").0, Runnable::Converge);
1080        assert_eq!(
1081            run_of("speccoder converge --feature {feature}").0,
1082            Runnable::Converge
1083        );
1084        assert_eq!(
1085            run_of("speccoder task skip {task} --reason \"…\""),
1086            (
1087                Runnable::Skip { task: t() },
1088                Some("Why it is skipped".to_owned()),
1089                None
1090            )
1091        );
1092        assert_eq!(
1093            run_of("speccoder task hint {task} \"…\"").1.as_deref(),
1094            Some("The hint")
1095        );
1096        assert_eq!(
1097            run_of("speccoder task done {task}").0,
1098            Runnable::Done { task: t() }
1099        );
1100        assert_eq!(
1101            run_of("speccoder task rule {task} --tests-right --reason \"…\"").0,
1102            Runnable::Rule { task: t() }
1103        );
1104        assert_eq!(
1105            run_of("speccoder bug keep {feature}/{task} --reason \"kept as a regression test\""),
1106            (
1107                Runnable::BugKeep {
1108                    task: "001-x/T001".to_owned()
1109                },
1110                None,
1111                Some("kept as a regression test".to_owned())
1112            )
1113        );
1114        assert_eq!(
1115            run_of("speccoder spec tasks"),
1116            (
1117                Runnable::Skill {
1118                    name: "tasks".to_owned(),
1119                    arguments: String::new()
1120                },
1121                None,
1122                None
1123            )
1124        );
1125        assert_eq!(
1126            run_of("speccoder spec plan --through tasks").0,
1127            Runnable::PlanThrough {
1128                from: PlanFrom::Continue,
1129                through: Through::Tasks
1130            }
1131        );
1132        assert_eq!(
1133            run_of("speccoder review answer Q1 \"…\"").1.as_deref(),
1134            Some("Your answer")
1135        );
1136        assert_eq!(run_of("speccoder fix").0, Runnable::FixSetup);
1137        assert!(matches!(
1138            h("speccoder pr --yes"),
1139            How::Perform { run, confirm: Some(_), .. } if *run == Runnable::PullRequest
1140        ));
1141        assert!(matches!(
1142            h("speccoder task drop {task}"),
1143            How::Perform { run, confirm: Some(_), .. }
1144                if matches!(*run, Runnable::DropSetAside { .. })
1145        ));
1146        assert_eq!(
1147            run_of("speccoder feature <name>"),
1148            (Runnable::Feature, Some("The feature".to_owned()), None)
1149        );
1150        // The feature a line names travels with it.
1151        assert!(matches!(
1152            h("speccoder run --feature 002-y"),
1153            How::Perform { feature: Some(f), .. } if f == "002-y"
1154        ));
1155    }
1156
1157    #[test]
1158    fn what_has_no_runnable_runs_as_written_when_speccoder_wrote_it() {
1159        assert_eq!(
1160            argvs_of("speccoder run --ignore-checklists").0,
1161            vec![s(&["speccoder", "run", "--ignore-checklists"])]
1162        );
1163        assert_eq!(
1164            argvs_of("speccoder attest verify --feature {feature} --json").0,
1165            vec![s(&[
1166                "speccoder",
1167                "attest",
1168                "verify",
1169                "--feature",
1170                "001-x",
1171                "--json"
1172            ])]
1173        );
1174        assert_eq!(
1175            argvs_of("speccoder spec archive --yes").0,
1176            vec![s(&["speccoder", "spec", "archive", "--yes"])]
1177        );
1178    }
1179
1180    #[test]
1181    fn the_persons_own_commands_are_exact_argvs() {
1182        // A constant line of speccoder's: run as it reads.
1183        assert_eq!(
1184            argvs_of("git add speccoder.toml && git commit -m \"speccoder: config\"").0,
1185            vec![
1186                s(&["git", "add", "speccoder.toml"]),
1187                s(&["git", "commit", "-m", "speccoder: config"])
1188            ]
1189        );
1190        assert_eq!(argvs_of("git stash -u").0, vec![s(&["git", "stash", "-u"])]);
1191        // Built as argvs by its source: run as given, shown as a shell reads it.
1192        let built = person(vec![s(&["git", "add", "--", "my file.toml", "it's"])]);
1193        assert_eq!(built.command, "git add -- 'my file.toml' 'it'\\''s'");
1194        assert!(matches!(&built.how, How::Command { argvs, .. } if argvs[0][3] == "my file.toml"));
1195        let typed = vec![vec![s(&["npm", "install"])]];
1196        let steps = steps_with(
1197            &["npm install".to_owned(), "npm ci".to_owned()],
1198            &typed,
1199            Context::default(),
1200        );
1201        assert!(matches!(&steps[0].how, How::Command { argvs, .. } if argvs == &typed[0]));
1202        // A line nobody built as argvs is never run from its text.
1203        assert!(steps[1].how.copy_only(), "{:?}", steps[1]);
1204    }
1205
1206    #[test]
1207    fn spliced_values_never_become_a_command() {
1208        // A file named `.x&&make`, spliced into a line, and a comment.
1209        for line in [
1210            "git add .x&&make && git commit -m \"Track project config\"",
1211            "git add .x && make && git commit -m init",
1212            "git switch B && git merge b   # resolve the conflicts",
1213            "git worktree remove /tmp/wt --force",
1214            "npm install",
1215            "speccoder run --feature 001-a --yes",
1216            "speccoder attest verify --feature 001-a --yes",
1217        ] {
1218            let how = h(line);
1219            assert!(!matches!(&how, How::Command { .. }), "{line}: {how:?}");
1220        }
1221        // `&&` separates only as a word; a word with it is no line at all.
1222        assert_eq!(argvs("a .x&&make"), None);
1223        assert_eq!(
1224            argvs("git merge b   # resolve"),
1225            Some(vec![s(&["git", "merge", "b"])])
1226        );
1227        // A slot takes a plain identifier, never a flag or two words.
1228        assert!(matches!(
1229            h("speccoder run --task T004 --fresh-acceptance"),
1230            How::Command { .. }
1231        ));
1232        assert!(!matches!(
1233            h("speccoder run --task --yes --fresh-acceptance"),
1234            How::Command { .. }
1235        ));
1236        assert!(matches!(
1237            h("speccoder run --ignore-checklists --feature 001-x"),
1238            How::Command { .. }
1239        ));
1240    }
1241
1242    #[test]
1243    fn free_slots_and_lines_about_other_commands_are_never_signed() {
1244        // `speccoder {command} --help` names any command: said, not run.
1245        for line in [
1246            "speccoder run --help",
1247            "speccoder run --json",
1248            "speccoder doctor --events",
1249        ] {
1250            assert!(!matches!(h(line), How::Command { .. }), "{line}");
1251        }
1252        // A key is the settings' to show, never a free slot.
1253        assert_eq!(
1254            view_of("speccoder config get model.region").0,
1255            View::Settings
1256        );
1257        assert_eq!(
1258            view_of("speccoder config unset model.base_url").0,
1259            View::Settings
1260        );
1261    }
1262
1263    #[test]
1264    fn what_writes_outside_the_project_says_so() {
1265        let global = person(vec![s(&["git", "config", "--global", "gpg.format", "ssh"])]);
1266        assert!(
1267            matches!(global.how, How::Command { outside: Some(o), .. } if o == "changes your global git config")
1268        );
1269        let local = person(vec![s(&["npm", "install"])]);
1270        assert!(matches!(local.how, How::Command { outside: None, .. }));
1271    }
1272
1273    #[test]
1274    fn what_reads_the_keyboard_is_known() {
1275        for argv in [
1276            &["copilot", "login"][..],
1277            &["opencode", "auth", "login"],
1278            &["gh", "auth", "login"],
1279            &["aws", "sso", "login", "--profile", "dev"],
1280            &["ssh-keygen", "-t", "ed25519"],
1281            &["git", "commit"],
1282            &["git", "merge", "b"],
1283            &["specify", "init", "--here"],
1284            &["speccoder", "spec", "archive"],
1285        ] {
1286            assert!(interactive(&s(argv)), "{argv:?}");
1287        }
1288        for argv in [
1289            &["brew", "install", "gh"][..],
1290            &["git", "commit", "-m", "x"],
1291            &["ssh-keygen", "-t", "ed25519", "-N", "", "-f", "k"],
1292            &["speccoder", "spec", "archive", "--yes"],
1293        ] {
1294            assert!(!interactive(&s(argv)), "{argv:?}");
1295        }
1296    }
1297
1298    #[test]
1299    fn only_a_signer_on_this_thread_signs_and_only_what_is_built() {
1300        let unsigned = person(vec![s(&["npm", "install"])]);
1301        assert!(matches!(unsigned.how, How::Command { token: None, .. }));
1302        let signed = signing(
1303            std::rc::Rc::new(|a: &[Vec<String>], i: bool| format!("{}:{i}", a.len())),
1304            || person(vec![s(&["npm", "install"])]),
1305        );
1306        assert!(matches!(signed.how, How::Command { token: Some(t), .. } if t == "1:false"));
1307        // Reset once done.
1308        assert!(matches!(
1309            person(vec![s(&["x"])]).how,
1310            How::Command { token: None, .. }
1311        ));
1312    }
1313
1314    #[test]
1315    fn what_cannot_run_here_says_why() {
1316        assert!(matches!(
1317            h("export ANTHROPIC_API_KEY=…"),
1318            How::Shell { setting: Some(s), .. } if s == "model"
1319        ));
1320        assert!(matches!(
1321            h("export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""),
1322            How::Shell { setting: None, .. }
1323        ));
1324        for line in [
1325            "→ re-run with -v and report the output",
1326            "set [sandbox] mode = \"off\"",
1327            "curl https://example.com | sh",
1328            "sudo apt-get install bubblewrap",
1329            "git remote add origin <url>",
1330            "git diff -- <unit>/review.json",
1331            "echo $HOME",
1332            "speccoder eval --keep <dir>",
1333            "speccoder pr --remote <name>",
1334        ] {
1335            assert!(h(line).copy_only(), "{line}: {:?}", h(line));
1336        }
1337    }
1338}