Skip to main content

xtask/
autogen.rs

1//! Generators for synthetic test documents, invoked as `cargo xtask autogen
2//! <command>`. Output is JSON (blockworx's document format), or a `.bwx.zip`
3//! container the web shell's File ▸ Import takes; the scene is built by
4//! [`blockworx_doc::fixtures::scale`], so the file written here and the scene
5//! the editor's tests lower in-process cannot drift apart. See
6//! `docs/json-format.md` for the format.
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use blockworx_doc::document::Document;
13use blockworx_doc::fixtures::scale::build_scale;
14use blockworx_store::handle::{Clock, Store};
15use blockworx_store::record::Identity;
16use blockworx_store::storage::{Memory, Name, ready_now};
17use blockworx_store::transfer;
18use clap::{Args, Subcommand};
19
20#[derive(Subcommand)]
21pub enum AutogenCmd {
22    /// Emit a square N×N grid of identical `Add` blocks, fully wired — with
23    /// every wire's corners stored, as a saved document has them — to stress
24    /// the router and editor.
25    Scale(ScaleArgs),
26    /// Pack a JSON document into a `.bwx.zip` container the web shell's
27    /// File ▸ Import takes.
28    Pack(PackArgs),
29}
30
31#[derive(Args)]
32pub struct PackArgs {
33    /// The JSON document to pack.
34    input: PathBuf,
35    /// Destination `.bwx.zip`.
36    output: PathBuf,
37}
38
39#[derive(Args)]
40pub struct ScaleArgs {
41    /// Grid dimension: build an N×N grid of blocks.
42    n: usize,
43    /// Destination: a `.zip` (e.g. `scale.bwx.zip`) is written as an
44    /// importable container, anything else as a JSON document.
45    output: PathBuf,
46}
47
48/// What a destination is written as, told by its extension.
49enum Form {
50    Json,
51    Archive,
52}
53
54impl Form {
55    fn of(output: &Path) -> Self {
56        let zipped = output
57            .extension()
58            .is_some_and(|ext| ext.eq_ignore_ascii_case("zip"));
59        if zipped { Self::Archive } else { Self::Json }
60    }
61}
62
63pub fn run(cmd: &AutogenCmd) -> Result<()> {
64    match cmd {
65        AutogenCmd::Scale(args) => scale(args),
66        AutogenCmd::Pack(args) => pack(args),
67    }
68}
69
70fn pack(args: &PackArgs) -> Result<()> {
71    let read =
72        fs::read(&args.input).with_context(|| format!("reading {}", args.input.display()))?;
73    let document: Document = serde_json::from_slice(&read)
74        .with_context(|| format!("{} is not a document", args.input.display()))?;
75    let bytes = archive(&document, &container_name(&args.output)?)?;
76    write(&args.output, &bytes)?;
77    eprintln!(
78        "packed {} ({} blocks, {} bytes) into {}",
79        args.input.display(),
80        document.blocks().count(),
81        bytes.len(),
82        args.output.display()
83    );
84    Ok(())
85}
86
87fn write(output: &Path, bytes: &[u8]) -> Result<()> {
88    if let Some(dir) = output.parent().filter(|dir| !dir.as_os_str().is_empty()) {
89        fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
90    }
91    fs::write(output, bytes).with_context(|| format!("writing {}", output.display()))
92}
93
94fn scale(args: &ScaleArgs) -> Result<()> {
95    let n = args.n;
96    if n == 0 {
97        anyhow::bail!("grid dimension N must be at least 1");
98    }
99
100    let document = settled(build_scale(n))?;
101    let bytes = match Form::of(&args.output) {
102        Form::Json => {
103            let mut json = serde_json::to_string_pretty(&document)
104                .context("serializing the generated document")?;
105            json.push('\n');
106            json.into_bytes()
107        }
108        Form::Archive => archive(&document, &container_name(&args.output)?)?,
109    };
110    write(&args.output, &bytes)?;
111    let blocks = n * n;
112    eprintln!(
113        "wrote autogen scale ({n}×{n} = {blocks} blocks, {} bytes) to {}",
114        bytes.len(),
115        args.output.display()
116    );
117    Ok(())
118}
119
120/// `document` with its wires' corners stored, as a document the editor saved
121/// carries them — without them, opening it routes every wire.
122fn settled(document: Document) -> Result<Document> {
123    blockworx_editor::widget::drawing::settle_corners(document)
124        .map_err(|refusal| anyhow::anyhow!("storing the wires' corners: {refusal}"))
125}
126
127/// The container an archive at `output` carries, named as the web shell's
128/// import names it: `scale.bwx.zip` carries `scale.bwx`.
129fn container_name(output: &Path) -> Result<Name> {
130    output
131        .file_name()
132        .and_then(|file| file.to_str())
133        .and_then(Name::of_archive)
134        .with_context(|| format!("{} names no diagram", output.display()))
135}
136
137/// `document` as a packed container: one seeded rev creating all of it, and
138/// its projection beside it.
139fn archive(document: &Document, name: &Name) -> Result<Vec<u8>> {
140    let label = format!("Generated {name}");
141    let commits: Vec<_> = document.creating_commit(&label).into_iter().collect();
142    let storage = Memory::new(name.as_str());
143    Store::seeded(
144        storage.clone(),
145        Clock::System,
146        &commits,
147        &Identity::new("cargo xtask autogen"),
148    )
149    .context("seeding the container")?;
150    ready_now(transfer::pack(&storage)).context("packing the container")
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn a_scale_archive_unpacks_into_a_container_holding_the_grid() {
159        let n = 3;
160        let generated = build_scale(n);
161        assert_eq!(
162            generated.blocks().count(),
163            n * n + 1,
164            "precondition: the grid's blocks and the sheet wrapping them",
165        );
166        let name = container_name(Path::new("target/autogen/scale-3.bwx.zip")).expect("a name");
167        assert_eq!(name.as_str(), "scale-3.bwx");
168
169        let packed = archive(&generated, &name).expect("the archive");
170        let arrived = Memory::new(name.as_str());
171        ready_now(transfer::unpack(&arrived, &packed)).expect("the archive lays down");
172        let opened = Store::open(arrived, Clock::System).expect("a container");
173
174        assert!(
175            opened.read_only_reason().is_none(),
176            "the container verifies"
177        );
178        assert_eq!(opened.document().blocks().count(), n * n + 1);
179        assert_eq!(opened.document(), &generated);
180    }
181
182    #[test]
183    fn the_destination_extension_picks_the_form() {
184        assert!(matches!(
185            Form::of(Path::new("scale.bwx.zip")),
186            Form::Archive
187        ));
188        assert!(matches!(Form::of(Path::new("scale.ZIP")), Form::Archive));
189        assert!(matches!(Form::of(Path::new("scale.json")), Form::Json));
190    }
191}