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" => asking(
830                    Runnable::Rule { task: t },
831                    a,
832                    flag(a, "--reason"),
833                    "Why the tests are right",
834                ),
835                "done" => perform(Runnable::Done { task: t }, a),
836                "defer" => perform(
837                    Runnable::DeferAcceptance {
838                        task: t,
839                        revoke: a.iter().any(|w| w == "--revoke"),
840                    },
841                    a,
842                ),
843                "restore" => perform(Runnable::KeepSetAside { task: t }, a),
844                "drop" => confirmed(
845                    Runnable::DropSetAside { task: t.clone() },
846                    a,
847                    &format!(
848                        "Discard the changes set aside before {t}? They are deleted, not kept anywhere."
849                    ),
850                ),
851                _ => view(View::Task, Some(&t)),
852            }
853        }
854        (Some("review"), Some("approve")) if a.len() == 2 => perform(
855            Runnable::Review {
856                ids: Vec::new(),
857                verdict: "approve".to_owned(),
858            },
859            a,
860        ),
861        (Some("review"), Some("answer")) => match task(2) {
862            Some(q) => asking(
863                Runnable::Review {
864                    ids: vec![q],
865                    verdict: "answer".to_owned(),
866                },
867                a,
868                positional(a, 3),
869                "Your answer",
870            ),
871            None => view(View::Review, None),
872        },
873        (Some("review"), _) => view(View::Review, None),
874        // Another project's: run there.
875        (Some(_), _) if has(a, "-C") => words(Some("it is another project's: run it there")),
876        (Some("spec"), Some("archive")) => run_cmd(),
877        // The offer lives in Needs You; declining it is one action.
878        // A session is chosen from, resumed and started in the sheet.
879        (Some("spec"), Some("explore")) => view(
880            View::Explore,
881            match positional(a, 2) {
882                Some("choose") => positional(a, 4),
883                Some("resume" | "start") => positional(a, 3),
884                _ => None,
885            }
886            .filter(|s| s.starts_with("ex-")),
887        ),
888        (Some("spec"), Some("promote")) => match positional(a, 2) {
889            Some(task) if has(a, "--decline") => perform(
890                Runnable::DeclinePromotion {
891                    task: task.to_owned(),
892                },
893                a,
894            ),
895            _ => view(View::Approvals, None),
896        },
897        (Some("spec"), Some(name)) if !has_blank(name) && !name.starts_with('-') => {
898            let given = positional(a, 2);
899            if let Some(through) = flag(a, "--through").and_then(Through::parse) {
900                // Words to plan from: the new-feature sheet asks for them.
901                return match given {
902                    Some(_) => view(View::NewFeature, None),
903                    None => perform(
904                        Runnable::PlanThrough {
905                            from: PlanFrom::Continue,
906                            through,
907                        },
908                        a,
909                    ),
910                };
911            }
912            if a.iter()
913                .skip(2)
914                .any(|w| w.starts_with('-') && w != "--feature")
915            {
916                // A flag only the command takes.
917                return run_cmd();
918            }
919            let skill = |arguments: &str| Runnable::Skill {
920                name: name.to_owned(),
921                arguments: arguments.to_owned(),
922            };
923            match given {
924                None => perform(skill(""), a),
925                Some(w) if has_blank(w) => asking(skill(""), a, None, "Describe it"),
926                Some(w) => perform(skill(w), a),
927            }
928        }
929        (Some("bug"), Some("keep")) => match task(2) {
930            Some(t) => asking(
931                Runnable::BugKeep { task: t },
932                a,
933                flag(a, "--reason"),
934                "Why it is kept",
935            ),
936            None => view(View::Bug, None),
937        },
938        (Some("bug"), Some("run")) => match flag(a, "--task").filter(|t| !has_blank(t)) {
939            Some(t) => perform(
940                Runnable::BugRun {
941                    slug: None,
942                    task: Some(t.to_owned()),
943                },
944                a,
945            ),
946            None => view(View::Bug, None),
947        },
948        (Some("bug"), _) => view(View::Bug, None),
949        (Some("do"), _) => view(View::QuickChange, None),
950        (Some("ask"), _) => view(View::Ask, None),
951        (Some("pr"), _) if flag(a, "--remote").is_some() => {
952            words(Some("name the remote it pushes to"))
953        }
954        (Some("pr"), _) => confirmed(Runnable::PullRequest, a, PR_CONFIRM),
955        (Some("fix"), _) if a.len() == 1 => perform(Runnable::FixSetup, a),
956        (Some("preset"), w) if w != Some("add") => words(None),
957        (Some("preset"), _) => confirmed(
958            Runnable::PresetAdd { update: false },
959            a,
960            "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.",
961        ),
962        (Some("feature"), Some("similar")) => view(View::NewFeature, None),
963        (Some("feature"), None) => asking(Runnable::Feature, a, None, "The feature"),
964        (Some("feature"), Some(name)) if !name.starts_with('-') => asking(
965            Runnable::Feature,
966            a,
967            Some(name).filter(|n| !has_blank(n)),
968            "The feature",
969        ),
970        (Some("init"), _) => view(View::Setup, None),
971        (Some("config"), None | Some("get" | "unset")) => view(View::Settings, None),
972        (Some("config"), Some("set")) if a.iter().any(|w| has_blank(w)) => {
973            view(View::Settings, None)
974        }
975        (Some("new"), _) => view(View::NewProject, None),
976        (Some("auth"), _) => view(View::GitHub, None),
977        (Some(dev), _) if DEVELOPER.contains(&dev) => {
978            words(Some("a developer tool: run it in your shell"))
979        }
980        _ => run_cmd(),
981    }
982}
983
984impl How {
985    /// It is a copy and nothing else: words, or a shell line.
986    #[must_use]
987    pub const fn copy_only(&self) -> bool {
988        matches!(self, Self::Words { .. } | Self::Shell { .. })
989    }
990}
991
992/// Every line of `fix`, as steps.
993#[must_use]
994pub fn steps(fix: &[String], ctx: Context<'_>) -> Vec<Step> {
995    steps_with(fix, &[], ctx)
996}
997
998#[cfg(test)]
999mod tests {
1000    use super::*;
1001
1002    fn h(line: &str) -> How {
1003        step_of(
1004            line,
1005            Context {
1006                feature: Some("001-x"),
1007                task: Some("T001"),
1008            },
1009        )
1010        .how
1011    }
1012
1013    fn run_of(line: &str) -> (Runnable, Option<String>, Option<String>) {
1014        match h(line) {
1015            How::Perform {
1016                run, input, text, ..
1017            } => (*run, input, text),
1018            other => panic!("{line}: {other:?}"),
1019        }
1020    }
1021
1022    fn view_of(line: &str) -> (View, Option<String>) {
1023        match h(line) {
1024            How::View { view, subject } => (view, subject),
1025            other => panic!("{line}: {other:?}"),
1026        }
1027    }
1028
1029    fn argvs_of(line: &str) -> (Vec<Vec<String>>, bool) {
1030        match h(line) {
1031            How::Command {
1032                argvs, interactive, ..
1033            } => (argvs, interactive),
1034            other => panic!("{line}: {other:?}"),
1035        }
1036    }
1037
1038    fn s(v: &[&str]) -> Vec<String> {
1039        v.iter().map(|w| (*w).to_owned()).collect()
1040    }
1041
1042    #[test]
1043    fn read_only_commands_open_their_view() {
1044        assert_eq!(view_of("speccoder doctor").0, View::Doctor);
1045        assert_eq!(
1046            view_of("speccoder doctor --feature {feature}").0,
1047            View::Doctor
1048        );
1049        assert_eq!(view_of("speccoder doctor --bug-report").0, View::BugReport);
1050        assert_eq!(view_of("speccoder report").0, View::Report);
1051        assert_eq!(view_of("speccoder status").0, View::Report);
1052        assert_eq!(view_of("speccoder report --approvals").0, View::Approvals);
1053        assert_eq!(view_of("speccoder report --live").0, View::Live);
1054        assert_eq!(view_of("speccoder report --feature <name>").0, View::Report);
1055        assert_eq!(
1056            view_of("speccoder explain NEEDS-APPROVAL"),
1057            (View::Explain, Some("NEEDS-APPROVAL".to_owned()))
1058        );
1059        assert_eq!(view_of("speccoder task approve {task}").0, View::Approvals);
1060        assert_eq!(view_of("speccoder init --force").0, View::Setup);
1061        assert_eq!(view_of("speccoder config").0, View::Settings);
1062        assert_eq!(
1063            view_of("speccoder config set checks.test.argv -- <your test command>").0,
1064            View::Settings
1065        );
1066        assert_eq!(view_of("speccoder do \"…\"").0, View::QuickChange);
1067        assert_eq!(
1068            view_of("speccoder bug \"<what happens>\" --expected \"<what should>\"").0,
1069            View::Bug
1070        );
1071        assert_eq!(
1072            view_of("speccoder review tests <REQ> --write").0,
1073            View::Review
1074        );
1075        assert_eq!(
1076            view_of("speccoder task acceptance-at {task} --story <US>"),
1077            (View::Task, Some("T001".to_owned()))
1078        );
1079    }
1080
1081    #[test]
1082    fn speccoder_commands_with_a_runnable_perform() {
1083        let t = || "T001".to_owned();
1084        assert_eq!(
1085            run_of("speccoder run").0,
1086            Runnable::Run {
1087                task: None,
1088                agent: None
1089            }
1090        );
1091        assert_eq!(
1092            run_of("speccoder run --task T004 --agent opencode").0,
1093            Runnable::Run {
1094                task: Some("T004".to_owned()),
1095                agent: Some("opencode".to_owned())
1096            }
1097        );
1098        assert_eq!(
1099            run_of("speccoder run --task {task}").0,
1100            Runnable::Run {
1101                task: Some(t()),
1102                agent: None
1103            }
1104        );
1105        assert_eq!(
1106            run_of("speccoder run --feature {feature} -v").0,
1107            Runnable::Run {
1108                task: None,
1109                agent: None
1110            }
1111        );
1112        assert_eq!(run_of("speccoder run --converge").0, Runnable::Converge);
1113        assert_eq!(
1114            run_of("speccoder converge --feature {feature}").0,
1115            Runnable::Converge
1116        );
1117        assert_eq!(
1118            run_of("speccoder task skip {task} --reason \"…\""),
1119            (
1120                Runnable::Skip { task: t() },
1121                Some("Why it is skipped".to_owned()),
1122                None
1123            )
1124        );
1125        assert_eq!(
1126            run_of("speccoder task hint {task} \"…\"").1.as_deref(),
1127            Some("The hint")
1128        );
1129        assert_eq!(
1130            run_of("speccoder task done {task}").0,
1131            Runnable::Done { task: t() }
1132        );
1133        assert_eq!(
1134            run_of("speccoder task rule {task} --tests-right --reason \"…\"").0,
1135            Runnable::Rule { task: t() }
1136        );
1137        assert_eq!(
1138            run_of("speccoder bug keep {feature}/{task} --reason \"kept as a regression test\""),
1139            (
1140                Runnable::BugKeep {
1141                    task: "001-x/T001".to_owned()
1142                },
1143                None,
1144                Some("kept as a regression test".to_owned())
1145            )
1146        );
1147        assert_eq!(
1148            run_of("speccoder spec tasks"),
1149            (
1150                Runnable::Skill {
1151                    name: "tasks".to_owned(),
1152                    arguments: String::new()
1153                },
1154                None,
1155                None
1156            )
1157        );
1158        assert_eq!(
1159            run_of("speccoder spec plan --through tasks").0,
1160            Runnable::PlanThrough {
1161                from: PlanFrom::Continue,
1162                through: Through::Tasks
1163            }
1164        );
1165        assert_eq!(
1166            run_of("speccoder review answer Q1 \"…\"").1.as_deref(),
1167            Some("Your answer")
1168        );
1169        assert_eq!(run_of("speccoder fix").0, Runnable::FixSetup);
1170        assert!(matches!(
1171            h("speccoder pr --yes"),
1172            How::Perform { run, confirm: Some(_), .. } if *run == Runnable::PullRequest
1173        ));
1174        assert!(matches!(
1175            h("speccoder task drop {task}"),
1176            How::Perform { run, confirm: Some(_), .. }
1177                if matches!(*run, Runnable::DropSetAside { .. })
1178        ));
1179        assert_eq!(
1180            run_of("speccoder feature <name>"),
1181            (Runnable::Feature, Some("The feature".to_owned()), None)
1182        );
1183        // The feature a line names travels with it.
1184        assert!(matches!(
1185            h("speccoder run --feature 002-y"),
1186            How::Perform { feature: Some(f), .. } if f == "002-y"
1187        ));
1188    }
1189
1190    #[test]
1191    fn what_has_no_runnable_runs_as_written_when_speccoder_wrote_it() {
1192        assert_eq!(
1193            argvs_of("speccoder run --ignore-checklists").0,
1194            vec![s(&["speccoder", "run", "--ignore-checklists"])]
1195        );
1196        assert_eq!(
1197            argvs_of("speccoder attest verify --feature {feature} --json").0,
1198            vec![s(&[
1199                "speccoder",
1200                "attest",
1201                "verify",
1202                "--feature",
1203                "001-x",
1204                "--json"
1205            ])]
1206        );
1207        assert_eq!(
1208            argvs_of("speccoder spec archive --yes").0,
1209            vec![s(&["speccoder", "spec", "archive", "--yes"])]
1210        );
1211    }
1212
1213    #[test]
1214    fn the_persons_own_commands_are_exact_argvs() {
1215        // A constant line of speccoder's: run as it reads.
1216        assert_eq!(
1217            argvs_of("git add speccoder.toml && git commit -m \"speccoder: config\"").0,
1218            vec![
1219                s(&["git", "add", "speccoder.toml"]),
1220                s(&["git", "commit", "-m", "speccoder: config"])
1221            ]
1222        );
1223        assert_eq!(argvs_of("git stash -u").0, vec![s(&["git", "stash", "-u"])]);
1224        // Built as argvs by its source: run as given, shown as a shell reads it.
1225        let built = person(vec![s(&["git", "add", "--", "my file.toml", "it's"])]);
1226        assert_eq!(built.command, "git add -- 'my file.toml' 'it'\\''s'");
1227        assert!(matches!(&built.how, How::Command { argvs, .. } if argvs[0][3] == "my file.toml"));
1228        let typed = vec![vec![s(&["npm", "install"])]];
1229        let steps = steps_with(
1230            &["npm install".to_owned(), "npm ci".to_owned()],
1231            &typed,
1232            Context::default(),
1233        );
1234        assert!(matches!(&steps[0].how, How::Command { argvs, .. } if argvs == &typed[0]));
1235        // A line nobody built as argvs is never run from its text.
1236        assert!(steps[1].how.copy_only(), "{:?}", steps[1]);
1237    }
1238
1239    #[test]
1240    fn spliced_values_never_become_a_command() {
1241        // A file named `.x&&make`, spliced into a line, and a comment.
1242        for line in [
1243            "git add .x&&make && git commit -m \"Track project config\"",
1244            "git add .x && make && git commit -m init",
1245            "git switch B && git merge b   # resolve the conflicts",
1246            "git worktree remove /tmp/wt --force",
1247            "npm install",
1248            "speccoder run --feature 001-a --yes",
1249            "speccoder attest verify --feature 001-a --yes",
1250        ] {
1251            let how = h(line);
1252            assert!(!matches!(&how, How::Command { .. }), "{line}: {how:?}");
1253        }
1254        // `&&` separates only as a word; a word with it is no line at all.
1255        assert_eq!(argvs("a .x&&make"), None);
1256        assert_eq!(
1257            argvs("git merge b   # resolve"),
1258            Some(vec![s(&["git", "merge", "b"])])
1259        );
1260        // A slot takes a plain identifier, never a flag or two words.
1261        assert!(matches!(
1262            h("speccoder run --task T004 --fresh-acceptance"),
1263            How::Command { .. }
1264        ));
1265        assert!(!matches!(
1266            h("speccoder run --task --yes --fresh-acceptance"),
1267            How::Command { .. }
1268        ));
1269        assert!(matches!(
1270            h("speccoder run --ignore-checklists --feature 001-x"),
1271            How::Command { .. }
1272        ));
1273    }
1274
1275    #[test]
1276    fn free_slots_and_lines_about_other_commands_are_never_signed() {
1277        // `speccoder {command} --help` names any command: said, not run.
1278        for line in [
1279            "speccoder run --help",
1280            "speccoder run --json",
1281            "speccoder doctor --events",
1282        ] {
1283            assert!(!matches!(h(line), How::Command { .. }), "{line}");
1284        }
1285        // A key is the settings' to show, never a free slot.
1286        assert_eq!(
1287            view_of("speccoder config get model.region").0,
1288            View::Settings
1289        );
1290        assert_eq!(
1291            view_of("speccoder config unset model.base_url").0,
1292            View::Settings
1293        );
1294    }
1295
1296    #[test]
1297    fn what_writes_outside_the_project_says_so() {
1298        let global = person(vec![s(&["git", "config", "--global", "gpg.format", "ssh"])]);
1299        assert!(
1300            matches!(global.how, How::Command { outside: Some(o), .. } if o == "changes your global git config")
1301        );
1302        let local = person(vec![s(&["npm", "install"])]);
1303        assert!(matches!(local.how, How::Command { outside: None, .. }));
1304    }
1305
1306    #[test]
1307    fn what_reads_the_keyboard_is_known() {
1308        for argv in [
1309            &["copilot", "login"][..],
1310            &["opencode", "auth", "login"],
1311            &["gh", "auth", "login"],
1312            &["aws", "sso", "login", "--profile", "dev"],
1313            &["ssh-keygen", "-t", "ed25519"],
1314            &["git", "commit"],
1315            &["git", "merge", "b"],
1316            &["specify", "init", "--here"],
1317            &["speccoder", "spec", "archive"],
1318        ] {
1319            assert!(interactive(&s(argv)), "{argv:?}");
1320        }
1321        for argv in [
1322            &["brew", "install", "gh"][..],
1323            &["git", "commit", "-m", "x"],
1324            &["ssh-keygen", "-t", "ed25519", "-N", "", "-f", "k"],
1325            &["speccoder", "spec", "archive", "--yes"],
1326        ] {
1327            assert!(!interactive(&s(argv)), "{argv:?}");
1328        }
1329    }
1330
1331    #[test]
1332    fn only_a_signer_on_this_thread_signs_and_only_what_is_built() {
1333        let unsigned = person(vec![s(&["npm", "install"])]);
1334        assert!(matches!(unsigned.how, How::Command { token: None, .. }));
1335        let signed = signing(
1336            std::rc::Rc::new(|a: &[Vec<String>], i: bool| format!("{}:{i}", a.len())),
1337            || person(vec![s(&["npm", "install"])]),
1338        );
1339        assert!(matches!(signed.how, How::Command { token: Some(t), .. } if t == "1:false"));
1340        // Reset once done.
1341        assert!(matches!(
1342            person(vec![s(&["x"])]).how,
1343            How::Command { token: None, .. }
1344        ));
1345    }
1346
1347    #[test]
1348    fn what_cannot_run_here_says_why() {
1349        assert!(matches!(
1350            h("export ANTHROPIC_API_KEY=…"),
1351            How::Shell { setting: Some(s), .. } if s == "model"
1352        ));
1353        assert!(matches!(
1354            h("export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""),
1355            How::Shell { setting: None, .. }
1356        ));
1357        for line in [
1358            "→ re-run with -v and report the output",
1359            "set [sandbox] mode = \"off\"",
1360            "curl https://example.com | sh",
1361            "sudo apt-get install bubblewrap",
1362            "git remote add origin <url>",
1363            "git diff -- <unit>/review.json",
1364            "echo $HOME",
1365            "speccoder eval --keep <dir>",
1366            "speccoder pr --remote <name>",
1367        ] {
1368            assert!(h(line).copy_only(), "{line}: {:?}", h(line));
1369        }
1370    }
1371}