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 let _ = writeln!(cargo_toml, "\n[profile.release]\nlto = true\nopt-level = 3\ncodegen-units = 1\npanic = \"abort\"\nstrip = true");
281
282 for dep in &output.dependencies {
284 if dep.name == "wasm-bindgen" && has_wasm_bindgen {
285 continue; }
287 if dep.features.is_empty() {
288 let _ = writeln!(cargo_toml, "{} = \"{}\"", dep.name, dep.version);
289 } else {
290 let feats = dep.features.iter()
291 .map(|f| format!("\"{}\"", f))
292 .collect::<Vec<_>>()
293 .join(", ");
294 let _ = writeln!(
295 cargo_toml,
296 "{} = {{ version = \"{}\", features = [{}] }}",
297 dep.name, dep.version, feats
298 );
299 }
300 }
301
302 fs::write(rust_project_dir.join("Cargo.toml"), &cargo_toml)
303 .map_err(|e| BuildError::Io(e.to_string()))?;
304
305 if config.release && resolved_target.is_none() {
308 let cargo_config_dir = rust_project_dir.join(".cargo");
309 fs::create_dir_all(&cargo_config_dir)
310 .map_err(|e| BuildError::Io(e.to_string()))?;
311 fs::write(
312 cargo_config_dir.join("config.toml"),
313 "[build]\nrustflags = [\"-C\", \"target-cpu=native\"]\n",
314 )
315 .map_err(|e| BuildError::Io(e.to_string()))?;
316 }
317
318 copy_runtime_crates(&rust_project_dir)?;
320
321 let mut cmd = Command::new("cargo");
323 cmd.arg("build").current_dir(&rust_project_dir);
324 if config.release {
325 cmd.arg("--release");
326 }
327 if let Some(target) = resolved_target {
328 cmd.arg("--target").arg(target);
329 }
330
331 let cmd_output = cmd.output().map_err(|e| BuildError::Io(e.to_string()))?;
332
333 if !cmd_output.status.success() {
334 let stderr = String::from_utf8_lossy(&cmd_output.stderr);
335 return Err(BuildError::Cargo(stderr.to_string()));
336 }
337
338 let cargo_target_str = if config.release { "release" } else { "debug" };
340 let binary_path = if config.lib_mode {
341 let lib_name = format!("lib{}", manifest.package.name.replace('-', "_"));
343 let ext = if cfg!(target_os = "macos") { "dylib" } else { "so" };
344 if let Some(target) = resolved_target {
345 rust_project_dir
346 .join("target")
347 .join(target)
348 .join(cargo_target_str)
349 .join(format!("{}.{}", lib_name, ext))
350 } else {
351 rust_project_dir
352 .join("target")
353 .join(cargo_target_str)
354 .join(format!("{}.{}", lib_name, ext))
355 }
356 } else {
357 let binary_name = if cfg!(windows) {
358 format!("{}.exe", manifest.package.name)
359 } else {
360 manifest.package.name.clone()
361 };
362 if let Some(target) = resolved_target {
363 rust_project_dir
364 .join("target")
365 .join(target)
366 .join(cargo_target_str)
367 .join(&binary_name)
368 } else {
369 rust_project_dir
370 .join("target")
371 .join(cargo_target_str)
372 .join(&binary_name)
373 }
374 };
375
376 if let Some(ref _c_header) = output.c_header {
378 let header_name = format!("{}.h", manifest.package.name);
379 let src_header = rust_project_dir.join(&header_name);
380 if src_header.exists() {
381 if let Some(parent) = binary_path.parent() {
382 let _ = fs::copy(&src_header, parent.join(&header_name));
383 }
384 }
385 }
386
387 Ok(BuildResult {
388 target_dir: build_dir,
389 binary_path,
390 })
391}
392
393fn strip_main_wrapper(code: &str) -> String {
396 if let Some(main_pos) = code.find("fn main() {") {
398 let before_main = &code[..main_pos];
399 let after_opening = &code[main_pos + "fn main() {".len()..];
401 if let Some(close_pos) = after_opening.rfind('}') {
402 let main_body = &after_opening[..close_pos];
403 let dedented: Vec<&str> = main_body.lines()
405 .map(|line| line.strip_prefix(" ").unwrap_or(line))
406 .collect();
407 format!("{}\n{}", before_main.trim_end(), dedented.join("\n"))
408 } else {
409 before_main.to_string()
410 }
411 } else {
412 code.to_string()
413 }
414}
415
416pub fn run(build_result: &BuildResult, args: &[String]) -> Result<i32, BuildError> {
433 let mut child = Command::new(&build_result.binary_path)
434 .args(args)
435 .spawn()
436 .map_err(|e| BuildError::Io(e.to_string()))?;
437
438 let status = child.wait().map_err(|e| BuildError::Io(e.to_string()))?;
439
440 Ok(status.code().unwrap_or(1))
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use tempfile::tempdir;
447
448 #[test]
449 fn find_project_root_finds_largo_toml() {
450 let temp = tempdir().unwrap();
451 let sub = temp.path().join("a/b/c");
452 fs::create_dir_all(&sub).unwrap();
453 fs::write(temp.path().join("Largo.toml"), "[package]\nname=\"test\"\n").unwrap();
454
455 let found = find_project_root(&sub);
456 assert!(found.is_some());
457 assert_eq!(found.unwrap(), temp.path());
458 }
459
460 #[test]
461 fn find_project_root_returns_none_if_not_found() {
462 let temp = tempdir().unwrap();
463 let found = find_project_root(temp.path());
464 assert!(found.is_none());
465 }
466
467 #[test]
468 fn strip_main_wrapper_extracts_body() {
469 let code = r#"use logicaffeine_data::*;
470
471fn add(a: i64, b: i64) -> i64 {
472 a + b
473}
474
475fn main() {
476 let x = add(1, 2);
477 println!("{}", x);
478}"#;
479 let result = strip_main_wrapper(code);
480 assert!(result.contains("fn add(a: i64, b: i64) -> i64"));
481 assert!(result.contains("let x = add(1, 2);"));
482 assert!(result.contains("println!(\"{}\", x);"));
483 assert!(!result.contains("fn main()"));
484 }
485
486 #[test]
487 fn strip_main_wrapper_preserves_imports() {
488 let code = "use logicaffeine_data::*;\nuse logicaffeine_system::*;\n\nfn main() {\n println!(\"hello\");\n}\n";
489 let result = strip_main_wrapper(code);
490 assert!(result.contains("use logicaffeine_data::*;"));
491 assert!(result.contains("use logicaffeine_system::*;"));
492 assert!(result.contains("println!(\"hello\");"));
493 assert!(!result.contains("fn main()"));
494 }
495
496 #[test]
497 fn strip_main_wrapper_no_main_returns_unchanged() {
498 let code = "fn add(a: i64, b: i64) -> i64 { a + b }";
499 let result = strip_main_wrapper(code);
500 assert_eq!(result, code);
501 }
502
503 #[test]
504 fn strip_main_wrapper_dedents_body() {
505 let code = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
506 let result = strip_main_wrapper(code);
507 assert!(result.contains("let x = 1;"));
509 assert!(result.contains("let y = 2;"));
510 for line in result.lines() {
512 if line.contains("let x") || line.contains("let y") {
513 assert!(!line.starts_with(" "), "Line should be dedented: {}", line);
514 }
515 }
516 }
517}