Skip to main content

pixel8_console/
builder.rs

1//! Building carts: run `cargo build --target wasm32-unknown-unknown` on a
2//! project, in the background, and report the result to the console.
3//!
4//! The same command works from a normal terminal — Pixel8 projects are
5//! plain Cargo crates, so the external-editor workflow is just "run cargo
6//! yourself" (or `pixel8 build <dir>`).
7
8use pixel8_runtime::{cart, project::Project};
9use std::{
10    path::Path,
11    process::Command,
12    sync::mpsc::{channel, Receiver},
13    time::{Duration, Instant},
14};
15
16pub struct BuildResult {
17    pub success: bool,
18    /// Console-ready error/diagnostic lines (already trimmed down).
19    pub errors: Vec<String>,
20    /// Non-fatal diagnostics, e.g. the cart exceeding the 128 K size limit.
21    pub warnings: Vec<String>,
22    pub duration: Duration,
23}
24
25/// A build running in a background thread.
26pub struct BuildJob {
27    rx: Receiver<BuildResult>,
28}
29
30impl BuildJob {
31    /// Poll for completion without blocking.
32    pub fn poll(&self) -> Option<BuildResult> {
33        self.rx.try_recv().ok()
34    }
35}
36
37/// Kick off a release wasm build of the project directory in a background thread.
38pub fn spawn_build(project_dir: &Path) -> BuildJob {
39    let dir = project_dir.to_path_buf();
40    let (tx, rx) = channel();
41    let started = Instant::now();
42    std::thread::spawn(move || {
43        let result = run_build(&dir, started);
44        let _ = tx.send(result);
45    });
46    BuildJob { rx }
47}
48
49/// Run a release wasm build of the project directory synchronously.
50/// Used by the headless `pixel8 build`/`export` subcommands.
51pub fn run_build(dir: &Path, started: Instant) -> BuildResult {
52    let mut cmd = Command::new("cargo");
53    cmd.args(["build", "--release", "--target", "wasm32-unknown-unknown"]);
54    let output = cmd
55        .current_dir(dir)
56        .env("CARGO_TERM_COLOR", "never")
57        .output();
58    match output {
59        Ok(out) if out.status.success() => {
60            let (errors, warnings) = post_build_diagnostics(dir);
61            let success = errors.is_empty();
62            BuildResult {
63                success,
64                errors,
65                warnings,
66                duration: started.elapsed(),
67            }
68        }
69        Ok(out) => BuildResult {
70            success: false,
71            errors: extract_errors(&String::from_utf8_lossy(&out.stderr)),
72            warnings: Vec::new(),
73            duration: started.elapsed(),
74        },
75        Err(e) => BuildResult {
76            success: false,
77            errors: vec![format!("could not run cargo: {e}")],
78            warnings: Vec::new(),
79            duration: started.elapsed(),
80        },
81    }
82}
83
84/// Pull the interesting lines out of cargo's stderr: error headers, their
85/// source locations, and the final summary. The console is 31 columns, so
86/// less is more.
87fn extract_errors(stderr: &str) -> Vec<String> {
88    let mut out = Vec::new();
89    for line in stderr.lines() {
90        let t = line.trim();
91        if t.starts_with("error") || t.starts_with("warning: unused") {
92            out.push(t.to_string());
93        } else if t.starts_with("-->") {
94            // Source location: keep just file:line:col.
95            out.push(format!("  {}", t.trim_start_matches("--> ").trim()));
96        }
97    }
98    if out.is_empty() {
99        out.push("build failed (see terminal for details)".into());
100    }
101    // Cap the flood; the console shows the rest of the story on request.
102    if out.len() > 24 {
103        let extra = out.len() - 24;
104        out.truncate(24);
105        out.push(format!("... and {extra} more lines"));
106    }
107    out
108}
109
110/// Gather all post-build diagnostics: memory-budget errors/warnings and the
111/// file-size warning. Returns `(errors, warnings)`. On any read or parse
112/// failure, the check is silently skipped — same defensive style as the rest
113/// of the build pipeline.
114fn post_build_diagnostics(dir: &Path) -> (Vec<String>, Vec<String>) {
115    let Ok(project) = Project::load(dir) else {
116        return (Vec::new(), Vec::new());
117    };
118    let Ok(wasm) = std::fs::read(project.wasm_path()) else {
119        return (Vec::new(), Vec::new());
120    };
121
122    let mut errors = Vec::new();
123    let mut warnings = Vec::new();
124
125    // Memory-budget check: the cart must start within the 128 K linear-memory cap.
126    if let Some(initial_bytes) = cart::initial_memory_bytes(&wasm) {
127        let (mem_errors, mem_warnings) = memory_diagnostics(initial_bytes);
128        errors.extend(mem_errors);
129        warnings.extend(mem_warnings);
130    }
131
132    // File-size check: warn now rather than at pack time. The hard gate is at
133    // export, so this stays a warning.
134    warnings.extend(wasm_size_warnings(dir));
135
136    (errors, warnings)
137}
138
139/// Memory-budget diagnostics for a freshly built cart, from its initial linear
140/// memory in bytes. Returns `(errors, warnings)`.
141/// - over the 128K cap (>= 3 pages): error — the cart won't load.
142/// - exactly the cap (2 pages, > 64K): warning — no heap headroom.
143/// - 1 page (<= 64K): nothing.
144fn memory_diagnostics(initial_bytes: usize) -> (Vec<String>, Vec<String>) {
145    let kib = initial_bytes / 1024;
146    if initial_bytes > cart::MEMORY_CAP {
147        (
148            vec![format!(
149                "error: cart needs {kib}K of RAM at startup; over the 128K cap — it won't \
150                 load. Reduce static data or the stack reserve (stack-size in \
151                 .cargo/config.toml)."
152            )],
153            Vec::new(),
154        )
155    } else if initial_bytes > cart::WASM_PAGE_SIZE {
156        (
157            Vec::new(),
158            vec![format!(
159                "warning: cart starts at {kib}K of RAM (both 64K pages) — no heap headroom left."
160            )],
161        )
162    } else {
163        (Vec::new(), Vec::new())
164    }
165}
166
167/// Warn when a freshly built cart exceeds the 128 K export size limit. The
168/// build still succeeds — the hard gate is at export — but the author should
169/// know now rather than at pack time.
170fn wasm_size_warnings(dir: &Path) -> Vec<String> {
171    let Ok(project) = Project::load(dir) else {
172        return Vec::new();
173    };
174    let Ok(meta) = std::fs::metadata(project.wasm_path()) else {
175        return Vec::new();
176    };
177    let size = meta.len() as usize;
178    if size > cart::MAX_WASM_SIZE {
179        vec![format!(
180            "warning: cart wasm is {size} bytes; over the 128K limit ({})",
181            cart::MAX_WASM_SIZE
182        )]
183    } else {
184        Vec::new()
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use std::{fs, path::PathBuf};
192
193    /// A temporary directory that removes itself on drop.
194    struct TempDir(PathBuf);
195
196    impl TempDir {
197        fn new(suffix: &str) -> Self {
198            let dir = std::env::temp_dir().join(format!(
199                "pixel8-builder-test-{suffix}-{}",
200                std::process::id()
201            ));
202            fs::create_dir_all(&dir).unwrap();
203            Self(dir)
204        }
205
206        fn path(&self) -> &Path {
207            &self.0
208        }
209    }
210
211    impl Drop for TempDir {
212        fn drop(&mut self) {
213            let _ = fs::remove_dir_all(&self.0);
214        }
215    }
216
217    /// Create a minimal project dir loadable by `Project::load` and return it
218    /// together with the wasm output path so the caller can populate it.
219    fn temp_project(suffix: &str) -> (TempDir, PathBuf) {
220        let tmp = TempDir::new(suffix);
221        let dir = tmp.path();
222        // Minimal Cargo.toml that `Project::load` (= parse_crate_name) can read.
223        fs::write(
224            dir.join("Cargo.toml"),
225            "[package]\nname = \"testcart\"\nversion = \"0.1.0\"\n",
226        )
227        .unwrap();
228        // Project::wasm_path() = target/wasm32-unknown-unknown/release/<name>.wasm
229        let wasm_dir = dir.join("target/wasm32-unknown-unknown/release");
230        fs::create_dir_all(&wasm_dir).unwrap();
231        let wasm_path = wasm_dir.join("testcart.wasm");
232        (tmp, wasm_path)
233    }
234
235    #[test]
236    fn wasm_size_warnings_absent_when_wasm_missing() {
237        let (tmp, _wasm_path) = temp_project("missing");
238        // No wasm file — should return an empty vec, not panic.
239        assert!(wasm_size_warnings(tmp.path()).is_empty());
240    }
241
242    #[test]
243    fn wasm_size_warnings_absent_for_small_wasm() {
244        let (tmp, wasm_path) = temp_project("small");
245        fs::write(&wasm_path, vec![0u8; 100]).unwrap();
246        assert!(wasm_size_warnings(tmp.path()).is_empty());
247    }
248
249    #[test]
250    fn wasm_size_warnings_fires_for_oversized_wasm() {
251        let (tmp, wasm_path) = temp_project("oversized");
252        fs::write(
253            &wasm_path,
254            vec![0u8; pixel8_runtime::cart::MAX_WASM_SIZE + 1],
255        )
256        .unwrap();
257        let warnings = wasm_size_warnings(tmp.path());
258        assert!(
259            !warnings.is_empty(),
260            "expected a warning for oversized wasm"
261        );
262        assert!(
263            warnings[0].contains("128K"),
264            "warning should mention 128K: {}",
265            warnings[0]
266        );
267    }
268
269    #[test]
270    fn memory_diagnostics_ok_for_one_page() {
271        let (errors, warnings) = memory_diagnostics(65_536);
272        assert_eq!(errors.len(), 0, "expected no errors for 1-page cart");
273        assert_eq!(warnings.len(), 0, "expected no warnings for 1-page cart");
274    }
275
276    #[test]
277    fn memory_diagnostics_warns_at_two_pages() {
278        let (errors, warnings) = memory_diagnostics(131_072);
279        assert_eq!(errors.len(), 0, "expected no errors at the cap");
280        assert_eq!(warnings.len(), 1, "expected one warning at 2 pages");
281        assert!(
282            warnings[0].contains("128K") || warnings[0].contains("128"),
283            "warning should mention the size: {}",
284            warnings[0]
285        );
286    }
287
288    #[test]
289    fn memory_diagnostics_errors_over_two_pages() {
290        let (errors, warnings) = memory_diagnostics(196_608);
291        assert_eq!(errors.len(), 1, "expected one error over the cap");
292        assert_eq!(warnings.len(), 0, "expected no warnings when over the cap");
293        assert!(
294            errors[0].contains("192K") || errors[0].contains("192"),
295            "error should mention the size: {}",
296            errors[0]
297        );
298    }
299
300    #[test]
301    fn extracts_error_lines() {
302        let stderr = "\
303   Compiling game v0.1.0
304error[E0425]: cannot find value `bogus` in this scope
305  --> src/lib.rs:10:9
306   |
30710 |         bogus += 1;
308   |         ^^^^^ not found in this scope
309error: aborting due to 1 previous error";
310        let lines = extract_errors(stderr);
311        assert!(lines[0].contains("E0425"));
312        assert!(lines[1].contains("src/lib.rs:10:9"));
313    }
314}