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 copy_runtime_crates(&rust_project_dir)?;
307
308 let mut cmd = Command::new("cargo");
310 cmd.arg("build").current_dir(&rust_project_dir);
311 if config.release {
312 cmd.arg("--release");
313 }
314 if let Some(target) = resolved_target {
315 cmd.arg("--target").arg(target);
316 }
317
318 let cmd_output = cmd.output().map_err(|e| BuildError::Io(e.to_string()))?;
319
320 if !cmd_output.status.success() {
321 let stderr = String::from_utf8_lossy(&cmd_output.stderr);
322 return Err(BuildError::Cargo(stderr.to_string()));
323 }
324
325 let cargo_target_str = if config.release { "release" } else { "debug" };
327 let binary_path = if config.lib_mode {
328 let lib_name = format!("lib{}", manifest.package.name.replace('-', "_"));
330 let ext = if cfg!(target_os = "macos") { "dylib" } else { "so" };
331 if let Some(target) = resolved_target {
332 rust_project_dir
333 .join("target")
334 .join(target)
335 .join(cargo_target_str)
336 .join(format!("{}.{}", lib_name, ext))
337 } else {
338 rust_project_dir
339 .join("target")
340 .join(cargo_target_str)
341 .join(format!("{}.{}", lib_name, ext))
342 }
343 } else {
344 let binary_name = if cfg!(windows) {
345 format!("{}.exe", manifest.package.name)
346 } else {
347 manifest.package.name.clone()
348 };
349 if let Some(target) = resolved_target {
350 rust_project_dir
351 .join("target")
352 .join(target)
353 .join(cargo_target_str)
354 .join(&binary_name)
355 } else {
356 rust_project_dir
357 .join("target")
358 .join(cargo_target_str)
359 .join(&binary_name)
360 }
361 };
362
363 if let Some(ref _c_header) = output.c_header {
365 let header_name = format!("{}.h", manifest.package.name);
366 let src_header = rust_project_dir.join(&header_name);
367 if src_header.exists() {
368 if let Some(parent) = binary_path.parent() {
369 let _ = fs::copy(&src_header, parent.join(&header_name));
370 }
371 }
372 }
373
374 Ok(BuildResult {
375 target_dir: build_dir,
376 binary_path,
377 })
378}
379
380fn strip_main_wrapper(code: &str) -> String {
383 if let Some(main_pos) = code.find("fn main() {") {
385 let before_main = &code[..main_pos];
386 let after_opening = &code[main_pos + "fn main() {".len()..];
388 if let Some(close_pos) = after_opening.rfind('}') {
389 let main_body = &after_opening[..close_pos];
390 let dedented: Vec<&str> = main_body.lines()
392 .map(|line| line.strip_prefix(" ").unwrap_or(line))
393 .collect();
394 format!("{}\n{}", before_main.trim_end(), dedented.join("\n"))
395 } else {
396 before_main.to_string()
397 }
398 } else {
399 code.to_string()
400 }
401}
402
403pub fn run(build_result: &BuildResult, args: &[String]) -> Result<i32, BuildError> {
420 let mut child = Command::new(&build_result.binary_path)
421 .args(args)
422 .spawn()
423 .map_err(|e| BuildError::Io(e.to_string()))?;
424
425 let status = child.wait().map_err(|e| BuildError::Io(e.to_string()))?;
426
427 Ok(status.code().unwrap_or(1))
428}
429
430#[cfg(test)]
431mod tests {
432 use super::*;
433 use tempfile::tempdir;
434
435 #[test]
436 fn find_project_root_finds_largo_toml() {
437 let temp = tempdir().unwrap();
438 let sub = temp.path().join("a/b/c");
439 fs::create_dir_all(&sub).unwrap();
440 fs::write(temp.path().join("Largo.toml"), "[package]\nname=\"test\"\n").unwrap();
441
442 let found = find_project_root(&sub);
443 assert!(found.is_some());
444 assert_eq!(found.unwrap(), temp.path());
445 }
446
447 #[test]
448 fn find_project_root_returns_none_if_not_found() {
449 let temp = tempdir().unwrap();
450 let found = find_project_root(temp.path());
451 assert!(found.is_none());
452 }
453
454 #[test]
455 fn strip_main_wrapper_extracts_body() {
456 let code = r#"use logicaffeine_data::*;
457
458fn add(a: i64, b: i64) -> i64 {
459 a + b
460}
461
462fn main() {
463 let x = add(1, 2);
464 println!("{}", x);
465}"#;
466 let result = strip_main_wrapper(code);
467 assert!(result.contains("fn add(a: i64, b: i64) -> i64"));
468 assert!(result.contains("let x = add(1, 2);"));
469 assert!(result.contains("println!(\"{}\", x);"));
470 assert!(!result.contains("fn main()"));
471 }
472
473 #[test]
474 fn strip_main_wrapper_preserves_imports() {
475 let code = "use logicaffeine_data::*;\nuse logicaffeine_system::*;\n\nfn main() {\n println!(\"hello\");\n}\n";
476 let result = strip_main_wrapper(code);
477 assert!(result.contains("use logicaffeine_data::*;"));
478 assert!(result.contains("use logicaffeine_system::*;"));
479 assert!(result.contains("println!(\"hello\");"));
480 assert!(!result.contains("fn main()"));
481 }
482
483 #[test]
484 fn strip_main_wrapper_no_main_returns_unchanged() {
485 let code = "fn add(a: i64, b: i64) -> i64 { a + b }";
486 let result = strip_main_wrapper(code);
487 assert_eq!(result, code);
488 }
489
490 #[test]
491 fn strip_main_wrapper_dedents_body() {
492 let code = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
493 let result = strip_main_wrapper(code);
494 assert!(result.contains("let x = 1;"));
496 assert!(result.contains("let y = 2;"));
497 for line in result.lines() {
499 if line.contains("let x") || line.contains("let y") {
500 assert!(!line.starts_with(" "), "Line should be dedented: {}", line);
501 }
502 }
503 }
504}