pixel8_console/
builder.rs1use 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 pub errors: Vec<String>,
20 pub warnings: Vec<String>,
22 pub duration: Duration,
23}
24
25pub struct BuildJob {
27 rx: Receiver<BuildResult>,
28}
29
30impl BuildJob {
31 pub fn poll(&self) -> Option<BuildResult> {
33 self.rx.try_recv().ok()
34 }
35}
36
37pub 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
49pub 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
84fn 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 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 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
110fn 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 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 warnings.extend(wasm_size_warnings(dir));
135
136 (errors, warnings)
137}
138
139fn 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
167fn 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 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 fn temp_project(suffix: &str) -> (TempDir, PathBuf) {
220 let tmp = TempDir::new(suffix);
221 let dir = tmp.path();
222 fs::write(
224 dir.join("Cargo.toml"),
225 "[package]\nname = \"testcart\"\nversion = \"0.1.0\"\n",
226 )
227 .unwrap();
228 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 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}