logicaffeine_cli/project/
build.rs1use std::fmt::Write as FmtWrite;
25use std::fs;
26use std::path::{Path, PathBuf};
27use std::process::Command;
28
29use crate::compile::compile_project;
30use logicaffeine_compile::compile::{copy_runtime_crates, CompileError};
31
32use super::manifest::{Manifest, ManifestError};
33
34pub struct BuildConfig {
56 pub project_dir: PathBuf,
58 pub release: bool,
60 pub lib_mode: bool,
62 pub target: Option<String>,
65}
66
67#[derive(Debug)]
72pub struct BuildResult {
73 pub target_dir: PathBuf,
75 pub binary_path: PathBuf,
77}
78
79#[derive(Debug)]
81pub enum BuildError {
82 Manifest(ManifestError),
84 Compile(CompileError),
86 Io(String),
88 Cargo(String),
90 NotFound(String),
92}
93
94impl std::fmt::Display for BuildError {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 BuildError::Manifest(e) => write!(f, "{}", e),
98 BuildError::Compile(e) => write!(f, "{}", e),
99 BuildError::Io(e) => write!(f, "IO error: {}", e),
100 BuildError::Cargo(e) => write!(f, "Cargo error: {}", e),
101 BuildError::NotFound(e) => write!(f, "Not found: {}", e),
102 }
103 }
104}
105
106impl std::error::Error for BuildError {}
107
108impl From<ManifestError> for BuildError {
109 fn from(e: ManifestError) -> Self {
110 BuildError::Manifest(e)
111 }
112}
113
114impl From<CompileError> for BuildError {
115 fn from(e: CompileError) -> Self {
116 BuildError::Compile(e)
117 }
118}
119
120pub fn find_project_root(start: &Path) -> Option<PathBuf> {
141 let mut current = if start.is_file() {
142 start.parent()?.to_path_buf()
143 } else {
144 start.to_path_buf()
145 };
146
147 loop {
148 if current.join("Largo.toml").exists() {
149 return Some(current);
150 }
151 if !current.pop() {
152 return None;
153 }
154 }
155}
156
157pub fn build(config: BuildConfig) -> Result<BuildResult, BuildError> {
176 let manifest = Manifest::load(&config.project_dir)?;
178
179 let entry_path = config.project_dir.join(&manifest.package.entry);
181 if entry_path.exists() {
182 return build_with_entry(&config, &manifest, &entry_path);
183 }
184
185 let md_path = entry_path.with_extension("md");
187 if md_path.exists() {
188 return build_with_entry(&config, &manifest, &md_path);
189 }
190
191 Err(BuildError::NotFound(format!(
192 "Entry point not found: {} (also tried .md)",
193 entry_path.display()
194 )))
195}
196
197fn build_with_entry(
198 config: &BuildConfig,
199 manifest: &Manifest,
200 entry_path: &Path,
201) -> Result<BuildResult, BuildError> {
202 let target_dir = config.project_dir.join("target");
204 let build_dir = if config.release {
205 target_dir.join("release")
206 } else {
207 target_dir.join("debug")
208 };
209 let rust_project_dir = build_dir.join("build");
210
211 if rust_project_dir.exists() {
213 fs::remove_dir_all(&rust_project_dir).map_err(|e| BuildError::Io(e.to_string()))?;
214 }
215 fs::create_dir_all(&rust_project_dir).map_err(|e| BuildError::Io(e.to_string()))?;
216
217 let output = compile_project(entry_path)?;
219
220 let src_dir = rust_project_dir.join("src");
222 fs::create_dir_all(&src_dir).map_err(|e| BuildError::Io(e.to_string()))?;
223
224 let rust_code = output.rust_code.clone();
225
226 if config.lib_mode {
227 let lib_code = strip_main_wrapper(&rust_code);
229 fs::write(src_dir.join("lib.rs"), lib_code).map_err(|e| BuildError::Io(e.to_string()))?;
230 } else {
231 fs::write(src_dir.join("main.rs"), &rust_code).map_err(|e| BuildError::Io(e.to_string()))?;
232 }
233
234 if let Some(ref c_header) = output.c_header {
236 let header_name = format!("{}.h", manifest.package.name);
237 fs::write(rust_project_dir.join(&header_name), c_header)
238 .map_err(|e| BuildError::Io(e.to_string()))?;
239 }
240
241 let resolved_target = config.target.as_deref().map(|t| {
243 if t.eq_ignore_ascii_case("wasm") {
244 "wasm32-unknown-unknown"
245 } else {
246 t
247 }
248 });
249
250 let mut cargo_toml = format!(
252 r#"[package]
253name = "{}"
254version = "{}"
255edition = "2021"
256"#,
257 manifest.package.name, manifest.package.version
258 );
259
260 if config.lib_mode {
262 let _ = writeln!(cargo_toml, "\n[lib]\ncrate-type = [\"cdylib\"]");
263 }
264
265 let _ = writeln!(cargo_toml, "\n[dependencies]");
266 let _ = writeln!(cargo_toml, "logicaffeine-data = {{ path = \"./crates/logicaffeine_data\" }}");
267 let _ = writeln!(cargo_toml, "logicaffeine-system = {{ path = \"./crates/logicaffeine_system\", features = [\"full\"] }}");
268 let _ = writeln!(cargo_toml, "tokio = {{ version = \"1\", features = [\"rt-multi-thread\", \"macros\"] }}");
269
270 let mut has_wasm_bindgen = false;
272 if let Some(target) = resolved_target {
273 if target.starts_with("wasm32") {
274 let _ = writeln!(cargo_toml, "wasm-bindgen = \"0.2\"");
275 has_wasm_bindgen = true;
276 }
277 }
278
279 for dep in &output.dependencies {
281 if dep.name == "wasm-bindgen" && has_wasm_bindgen {
282 continue; }
284 if dep.features.is_empty() {
285 let _ = writeln!(cargo_toml, "{} = \"{}\"", dep.name, dep.version);
286 } else {
287 let feats = dep.features.iter()
288 .map(|f| format!("\"{}\"", f))
289 .collect::<Vec<_>>()
290 .join(", ");
291 let _ = writeln!(
292 cargo_toml,
293 "{} = {{ version = \"{}\", features = [{}] }}",
294 dep.name, dep.version, feats
295 );
296 }
297 }
298
299 fs::write(rust_project_dir.join("Cargo.toml"), &cargo_toml)
300 .map_err(|e| BuildError::Io(e.to_string()))?;
301
302 copy_runtime_crates(&rust_project_dir)?;
304
305 let mut cmd = Command::new("cargo");
307 cmd.arg("build").current_dir(&rust_project_dir);
308 if config.release {
309 cmd.arg("--release");
310 }
311 if let Some(target) = resolved_target {
312 cmd.arg("--target").arg(target);
313 }
314
315 let cmd_output = cmd.output().map_err(|e| BuildError::Io(e.to_string()))?;
316
317 if !cmd_output.status.success() {
318 let stderr = String::from_utf8_lossy(&cmd_output.stderr);
319 return Err(BuildError::Cargo(stderr.to_string()));
320 }
321
322 let cargo_target_str = if config.release { "release" } else { "debug" };
324 let binary_path = if config.lib_mode {
325 let lib_name = format!("lib{}", manifest.package.name.replace('-', "_"));
327 let ext = if cfg!(target_os = "macos") { "dylib" } else { "so" };
328 if let Some(target) = resolved_target {
329 rust_project_dir
330 .join("target")
331 .join(target)
332 .join(cargo_target_str)
333 .join(format!("{}.{}", lib_name, ext))
334 } else {
335 rust_project_dir
336 .join("target")
337 .join(cargo_target_str)
338 .join(format!("{}.{}", lib_name, ext))
339 }
340 } else {
341 let binary_name = if cfg!(windows) {
342 format!("{}.exe", manifest.package.name)
343 } else {
344 manifest.package.name.clone()
345 };
346 if let Some(target) = resolved_target {
347 rust_project_dir
348 .join("target")
349 .join(target)
350 .join(cargo_target_str)
351 .join(&binary_name)
352 } else {
353 rust_project_dir
354 .join("target")
355 .join(cargo_target_str)
356 .join(&binary_name)
357 }
358 };
359
360 if let Some(ref _c_header) = output.c_header {
362 let header_name = format!("{}.h", manifest.package.name);
363 let src_header = rust_project_dir.join(&header_name);
364 if src_header.exists() {
365 if let Some(parent) = binary_path.parent() {
366 let _ = fs::copy(&src_header, parent.join(&header_name));
367 }
368 }
369 }
370
371 Ok(BuildResult {
372 target_dir: build_dir,
373 binary_path,
374 })
375}
376
377fn strip_main_wrapper(code: &str) -> String {
380 if let Some(main_pos) = code.find("fn main() {") {
382 let before_main = &code[..main_pos];
383 let after_opening = &code[main_pos + "fn main() {".len()..];
385 if let Some(close_pos) = after_opening.rfind('}') {
386 let main_body = &after_opening[..close_pos];
387 let dedented: Vec<&str> = main_body.lines()
389 .map(|line| line.strip_prefix(" ").unwrap_or(line))
390 .collect();
391 format!("{}\n{}", before_main.trim_end(), dedented.join("\n"))
392 } else {
393 before_main.to_string()
394 }
395 } else {
396 code.to_string()
397 }
398}
399
400pub fn run(build_result: &BuildResult, args: &[String]) -> Result<i32, BuildError> {
417 let mut child = Command::new(&build_result.binary_path)
418 .args(args)
419 .spawn()
420 .map_err(|e| BuildError::Io(e.to_string()))?;
421
422 let status = child.wait().map_err(|e| BuildError::Io(e.to_string()))?;
423
424 Ok(status.code().unwrap_or(1))
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use tempfile::tempdir;
431
432 #[test]
433 fn find_project_root_finds_largo_toml() {
434 let temp = tempdir().unwrap();
435 let sub = temp.path().join("a/b/c");
436 fs::create_dir_all(&sub).unwrap();
437 fs::write(temp.path().join("Largo.toml"), "[package]\nname=\"test\"\n").unwrap();
438
439 let found = find_project_root(&sub);
440 assert!(found.is_some());
441 assert_eq!(found.unwrap(), temp.path());
442 }
443
444 #[test]
445 fn find_project_root_returns_none_if_not_found() {
446 let temp = tempdir().unwrap();
447 let found = find_project_root(temp.path());
448 assert!(found.is_none());
449 }
450
451 #[test]
452 fn strip_main_wrapper_extracts_body() {
453 let code = r#"use logicaffeine_data::*;
454
455fn add(a: i64, b: i64) -> i64 {
456 a + b
457}
458
459fn main() {
460 let x = add(1, 2);
461 println!("{}", x);
462}"#;
463 let result = strip_main_wrapper(code);
464 assert!(result.contains("fn add(a: i64, b: i64) -> i64"));
465 assert!(result.contains("let x = add(1, 2);"));
466 assert!(result.contains("println!(\"{}\", x);"));
467 assert!(!result.contains("fn main()"));
468 }
469
470 #[test]
471 fn strip_main_wrapper_preserves_imports() {
472 let code = "use logicaffeine_data::*;\nuse logicaffeine_system::*;\n\nfn main() {\n println!(\"hello\");\n}\n";
473 let result = strip_main_wrapper(code);
474 assert!(result.contains("use logicaffeine_data::*;"));
475 assert!(result.contains("use logicaffeine_system::*;"));
476 assert!(result.contains("println!(\"hello\");"));
477 assert!(!result.contains("fn main()"));
478 }
479
480 #[test]
481 fn strip_main_wrapper_no_main_returns_unchanged() {
482 let code = "fn add(a: i64, b: i64) -> i64 { a + b }";
483 let result = strip_main_wrapper(code);
484 assert_eq!(result, code);
485 }
486
487 #[test]
488 fn strip_main_wrapper_dedents_body() {
489 let code = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
490 let result = strip_main_wrapper(code);
491 assert!(result.contains("let x = 1;"));
493 assert!(result.contains("let y = 2;"));
494 for line in result.lines() {
496 if line.contains("let x") || line.contains("let y") {
497 assert!(!line.starts_with(" "), "Line should be dedented: {}", line);
498 }
499 }
500 }
501}