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