1use 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 Init(InitArgs),
18 Golden(GoldenArgs),
20 Convert,
22}
23
24#[derive(Args)]
25pub struct InitArgs {
26 pub name: String,
28 #[arg(long)]
33 pub initial: PathBuf,
34}
35
36#[derive(Args)]
37pub struct GoldenArgs {
38 pub level: Option<String>,
41}
42
43#[derive(Args)]
44pub struct ReplayArgs {
45 pub level: String,
48 #[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
61fn 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 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
153fn 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
174fn 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
196fn 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
229fn 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
247fn level_id(name: &str) -> String {
250 name.trim_start_matches(|c: char| c.is_ascii_digit() || c == '_')
251 .replace('_', "-")
252}
253
254fn 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
264fn 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 (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}