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