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