Skip to main content

xtask/
tutorial.rs

1//! Tutorial-level authoring commands: scaffold and register a new level
2//! (`tutorial init`), refresh the replay goldens (`tutorial golden`), and
3//! launch a level in replay mode (`replay`). See `docs/tutorial-levels.md`
4//! for the format the scaffold follows.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result, bail};
9use clap::{Args, Subcommand};
10
11use crate::{cargo, workspace_root};
12
13#[derive(Subcommand)]
14pub enum TutorialCmd {
15    /// Scaffold a new level around an initial document, register it, seed its
16    /// golden, and verify the replay.
17    Init(InitArgs),
18    /// Regenerate the golden end states from the replay; review the diff.
19    Golden(GoldenArgs),
20    /// Convert every level into a `.bwx` container under `fixtures/tutorials/`.
21    Convert,
22}
23
24#[derive(Args)]
25pub struct InitArgs {
26    /// The level's file stem with its play-order prefix, e.g. `04_getting_around`.
27    pub name: String,
28    /// A KDL document to seed the level's `initial` block with. Levels are
29    /// the last KDL in the tree (D14) and keep their own dialect until Phase 7
30    /// converts them to containers, so this input is *not* the JSON the app
31    /// exports — feed it a legacy `.kdl` document.
32    #[arg(long)]
33    pub initial: PathBuf,
34}
35
36#[derive(Args)]
37pub struct GoldenArgs {
38    /// Show this level's full golden diff after the update (every golden still
39    /// regenerates and verifies).
40    pub level: Option<String>,
41}
42
43#[derive(Args)]
44pub struct ReplayArgs {
45    /// A level name (`04_getting_around`, `.kdl` optional) or a path to a
46    /// level file.
47    pub level: String,
48    /// Run the optimized build.
49    #[arg(long)]
50    pub release: bool,
51}
52
53pub fn run(cmd: &TutorialCmd) -> Result<()> {
54    match cmd {
55        TutorialCmd::Init(args) => init(args),
56        TutorialCmd::Golden(args) => golden(args),
57        TutorialCmd::Convert => convert(),
58    }
59}
60
61/// Lay every level out as a container in `fixtures/tutorials/`, then verify
62/// the result (playbook 7·9).
63///
64/// **A one-off: this command goes with the KDL parser at 7·11.** The level
65/// format it reads dies there, the containers it writes are the durable
66/// artifact, and a converter kept past its input is dead code. The
67/// conversion itself lives in `src/tutorial/convert.rs` and runs on every
68/// test run into a temporary directory; the environment variable below is
69/// what publishes it into the tree.
70fn convert() -> Result<()> {
71    const CONVERSION: &str =
72        "tutorial::convert::tests::the_converted_containers_match_the_replay_goldens";
73    eprintln!("\n── converting levels to containers");
74    cargo(&["test", "-p", "blockworx", "--lib", CONVERSION])
75        .env("BLOCKWORX_CONVERT_TUTORIALS", "1")
76        .run()
77        .context("the conversion failed — the level itself is broken")?;
78    eprintln!("── verifying the containers");
79    cargo(&["test", "-p", "blockworx", "--lib", "tutorial::convert"])
80        .run()
81        .context("the converted containers do not verify")?;
82    eprintln!("── container changes (review before committing):");
83    crate::command(
84        "git",
85        &["--no-pager", "diff", "--stat", "--", "fixtures/tutorials"],
86    )
87    .run()?;
88    Ok(())
89}
90
91fn levels_dir() -> PathBuf {
92    workspace_root().join("src/tutorial/levels")
93}
94
95fn init(args: &InitArgs) -> Result<()> {
96    let name = &args.name;
97    if name.is_empty()
98        || !name
99            .chars()
100            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
101    {
102        bail!("level names are snake_case with a numeric prefix, e.g. 04_getting_around");
103    }
104    let dir = levels_dir();
105    let level_path = dir.join(format!("{name}.kdl"));
106    if level_path.exists() {
107        bail!("{} already exists", level_path.display());
108    }
109    let initial = std::fs::read_to_string(&args.initial)
110        .with_context(|| format!("reading {}", args.initial.display()))?;
111
112    let id = level_id(name);
113    let title = level_title(name);
114    let (x, y, w, h) = top_block_camera(&initial).unwrap_or((0, 0, 28, 20));
115    let indented: String = initial
116        .lines()
117        .map(|line| {
118            if line.is_empty() {
119                "\n".to_owned()
120            } else {
121                format!("        {line}\n")
122            }
123        })
124        .collect();
125    let level = format!(
126        r#"level "{id}" title="{title}" {{
127    instructions "TODO: describe the task."
128    script {{
129        camera x={x} y={y} w={w} h={h}
130        instruct "TODO: narrate the first step"
131        pause secs=1.0
132    }}
133    initial {{
134{indented}    }}
135}}
136"#
137    );
138    std::fs::write(&level_path, level).with_context(|| format!("writing {name}.kdl"))?;
139    // Seed an empty golden: the golden is a projection of the replayed
140    // document, which only the regeneration below can produce.
141    let golden_path = dir.join(format!("{name}.golden.txt"));
142    std::fs::write(&golden_path, "").with_context(|| format!("writing {name}.golden.txt"))?;
143    register(name, &id)?;
144    eprintln!("scaffolded {} (level id {id:?})", level_path.display());
145    update_and_verify_goldens()?;
146    eprintln!(
147        "\nnext: edit the script in {}\n      preview with `cargo xtask replay {name}`",
148        level_path.display()
149    );
150    Ok(())
151}
152
153/// Append the level to `SOURCES` and its golden to `GOLDENS` in
154/// `levels/mod.rs`, keeping the two lists in play order together.
155fn register(name: &str, id: &str) -> Result<()> {
156    let path = levels_dir().join("mod.rs");
157    let mut src = std::fs::read_to_string(&path).context("reading levels/mod.rs")?;
158    src = insert_before_close(
159        &src,
160        "const SOURCES: &[&str] = &[",
161        &format!("    include_str!(\"{name}.kdl\"),\n"),
162    )?;
163    src = insert_before_close(
164        &src,
165        "pub(super) const GOLDENS: &[(&str, &str, &str)] = &[",
166        &format!(
167            "    (\n        \"{id}\",\n        \"src/tutorial/levels/{name}.golden.txt\",\n        include_str!(\"{name}.golden.txt\"),\n    ),\n"
168        ),
169    )?;
170    std::fs::write(&path, src).context("writing levels/mod.rs")?;
171    Ok(())
172}
173
174/// Insert `entry` just before the `];` that closes the list opened by `head`.
175fn insert_before_close(src: &str, head: &str, entry: &str) -> Result<String> {
176    let start = src
177        .find(head)
178        .with_context(|| format!("levels/mod.rs: `{head}` not found"))?;
179    let close = src[start..]
180        .find("];")
181        .with_context(|| format!("levels/mod.rs: `{head}` has no closing `];`"))?;
182    let at = start + close;
183    Ok(format!("{}{}{}", &src[..at], entry, &src[at..]))
184}
185
186fn golden(args: &GoldenArgs) -> Result<()> {
187    update_and_verify_goldens()?;
188    if let Some(level) = &args.level {
189        let stem = level.trim_end_matches(".kdl");
190        let path = format!("src/tutorial/levels/{stem}.golden.txt");
191        crate::command("git", &["--no-pager", "diff", "--", &path]).run()?;
192    }
193    Ok(())
194}
195
196/// Replay every level with golden updating on, then once more plain so a
197/// mismatch that survives regeneration still fails loudly; end with the
198/// diff stat — reviewing that diff is the acceptance step.
199fn update_and_verify_goldens() -> Result<()> {
200    let test = ["test", "-p", "blockworx", "--lib", "tutorial::"];
201    eprintln!("\n── regenerating goldens");
202    cargo(&test)
203        .env("BLOCKWORX_UPDATE_GOLDENS", "1")
204        .run()
205        .context("golden regeneration failed — the level itself is broken")?;
206    eprintln!("── verifying");
207    cargo(&test).run().context("replay verify failed")?;
208    eprintln!("── golden changes (review before committing):");
209    crate::command(
210        "git",
211        &["--no-pager", "diff", "--stat", "--", "src/tutorial/levels"],
212    )
213    .run()?;
214    Ok(())
215}
216
217pub fn replay(args: &ReplayArgs) -> Result<()> {
218    let path = resolve_level(&args.level)?;
219    let path = path.to_string_lossy().into_owned();
220    let mut argv = vec!["run", "-p", "blockworx"];
221    if args.release {
222        argv.push("--release");
223    }
224    argv.extend(["--", "--replay", &path]);
225    cargo(&argv).run()?;
226    Ok(())
227}
228
229/// A level argument is a path if it points at a file, else a name looked up
230/// in the levels directory (`.kdl` optional).
231fn resolve_level(level: &str) -> Result<PathBuf> {
232    let as_path = Path::new(level);
233    if as_path.is_file() {
234        return Ok(as_path.to_path_buf());
235    }
236    let stem = level.trim_end_matches(".kdl");
237    let registered = levels_dir().join(format!("{stem}.kdl"));
238    if registered.is_file() {
239        return Ok(registered);
240    }
241    bail!(
242        "no such level: `{level}` is not a file and {} does not exist",
243        registered.display()
244    );
245}
246
247/// The level id a file stem yields: the play-order prefix dropped,
248/// underscores hyphenated — `04_getting_around` → `getting-around`.
249fn level_id(name: &str) -> String {
250    name.trim_start_matches(|c: char| c.is_ascii_digit() || c == '_')
251        .replace('_', "-")
252}
253
254/// A starter title: the id's words with the first capitalized.
255fn level_title(name: &str) -> String {
256    let words = level_id(name).replace('-', " ");
257    let mut chars = words.chars();
258    match chars.next() {
259        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
260        None => words,
261    }
262}
263
264/// Best-effort camera from the initial document: the top block's rect, read
265/// with a line scan (the scaffold must not depend on the app crate).
266fn top_block_camera(initial: &str) -> Option<(i64, i64, i64, i64)> {
267    let top_line = initial.lines().find(|l| l.trim().starts_with("top "))?;
268    let top_id = top_line.split('"').nth(1)?;
269    let block_line = initial
270        .lines()
271        .find(|l| l.trim().starts_with(&format!("block \"{top_id}\"")))?;
272    let prop = |key: &str| -> Option<i64> {
273        block_line
274            .split_whitespace()
275            .find_map(|tok| tok.strip_prefix(&format!("{key}=")))?
276            .parse()
277            .ok()
278    };
279    let (x, y, w, h) = (prop("x")?, prop("y")?, prop("w")?, prop("h")?);
280    // Some documents carry a degenerate top rect; the camera needs area.
281    (w > 0 && h > 0).then_some((x, y, w, h))
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn names_derive_id_and_title() {
290        assert_eq!(level_id("04_getting_around"), "getting-around");
291        assert_eq!(level_title("04_getting_around"), "Getting around");
292        assert_eq!(level_id("first_block"), "first-block");
293    }
294
295    #[test]
296    fn camera_reads_the_top_block() {
297        let initial = "top \"b1\"\n\nblock \"b1\" x=0 y=0 w=28 h=16 {\n";
298        assert_eq!(top_block_camera(initial), Some((0, 0, 28, 16)));
299        assert_eq!(top_block_camera("no top here"), None);
300    }
301
302    #[test]
303    fn insertion_lands_before_the_list_close() {
304        let src = "const SOURCES: &[&str] = &[\n    a,\n];\nrest";
305        let out = insert_before_close(src, "const SOURCES: &[&str] = &[", "    b,\n").unwrap();
306        assert_eq!(out, "const SOURCES: &[&str] = &[\n    a,\n    b,\n];\nrest");
307    }
308}