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