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