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