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(CargoFailure),
91 Toolchain(String),
93 NotFound(String),
95}
96
97impl std::fmt::Display for BuildError {
98 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99 match self {
100 BuildError::Manifest(e) => write!(f, "{}", e),
101 BuildError::Compile(e) => write!(f, "{}", e),
102 BuildError::Io(e) => write!(f, "IO error: {}", e),
103 BuildError::Cargo(e) => write!(f, "{}", e),
104 BuildError::Toolchain(e) => write!(f, "{}", e),
105 BuildError::NotFound(e) => write!(f, "Not found: {}", e),
106 }
107 }
108}
109
110impl std::error::Error for BuildError {}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum CargoFailureKind {
116 DependencyResolution,
119 GeneratedCode,
122}
123
124#[derive(Debug)]
128pub struct CargoFailure {
129 pub kind: CargoFailureKind,
131 pub tail: String,
133 pub project_dir: PathBuf,
135}
136
137impl std::fmt::Display for CargoFailure {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 match self.kind {
140 CargoFailureKind::DependencyResolution => write!(
141 f,
142 "a `## Requires` dependency could not be resolved (cargo's output above has the details)"
143 ),
144 CargoFailureKind::GeneratedCode => write!(
145 f,
146 "the generated Rust failed to compile — this is a LOGOS compiler bug, not an error in your program (generated project: {})",
147 self.project_dir.display()
148 ),
149 }
150 }
151}
152
153pub fn requires_component_is_safe(s: &str) -> bool {
158 !s.is_empty()
159 && s.chars().all(|c| {
160 c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '^' | '~' | '*' | '<' | '>' | '=' | '+' | ',' | ' ')
161 })
162}
163
164pub fn classify_cargo_failure(stderr_tail: &str) -> CargoFailureKind {
171 const DEPENDENCY_MARKERS: [&str; 6] = [
172 "no matching package",
173 "failed to select a version",
174 "failed to get ",
175 "failed to fetch",
176 "failed to load source for dependency",
177 "unable to get packages from source",
178 ];
179 if DEPENDENCY_MARKERS.iter().any(|m| stderr_tail.contains(m)) {
180 CargoFailureKind::DependencyResolution
181 } else {
182 CargoFailureKind::GeneratedCode
183 }
184}
185
186impl From<ManifestError> for BuildError {
187 fn from(e: ManifestError) -> Self {
188 BuildError::Manifest(e)
189 }
190}
191
192impl From<CompileError> for BuildError {
193 fn from(e: CompileError) -> Self {
194 BuildError::Compile(e)
195 }
196}
197
198pub fn find_project_root(start: &Path) -> Option<PathBuf> {
219 let mut current = if start.is_file() {
220 start.parent()?.to_path_buf()
221 } else {
222 start.to_path_buf()
223 };
224
225 loop {
226 if current.join("Largo.toml").exists() {
227 return Some(current);
228 }
229 if !current.pop() {
230 return None;
231 }
232 }
233}
234
235pub fn build(config: BuildConfig) -> Result<BuildResult, BuildError> {
254 let manifest = Manifest::load(&config.project_dir)?;
256
257 let entry_path = config.project_dir.join(&manifest.package.entry);
259 if entry_path.exists() {
260 return build_with_entry(&config, &manifest, &entry_path);
261 }
262
263 let md_path = entry_path.with_extension("md");
265 if md_path.exists() {
266 return build_with_entry(&config, &manifest, &md_path);
267 }
268
269 Err(BuildError::NotFound(format!(
270 "Entry point not found: {} (also tried .md)",
271 entry_path.display()
272 )))
273}
274
275fn build_with_entry(
276 config: &BuildConfig,
277 manifest: &Manifest,
278 entry_path: &Path,
279) -> Result<BuildResult, BuildError> {
280 let started = std::time::Instant::now();
281 crate::ui::phase(
282 "Compiling",
283 format!(
284 "{} v{} (LOGOS → Rust)",
285 manifest.package.name, manifest.package.version
286 ),
287 );
288
289 let target_dir = config.project_dir.join("target");
291 let build_dir = if config.release {
292 target_dir.join("release")
293 } else {
294 target_dir.join("debug")
295 };
296 let rust_project_dir = build_dir.join("build");
297
298 if rust_project_dir.exists() {
300 fs::remove_dir_all(&rust_project_dir).map_err(|e| BuildError::Io(e.to_string()))?;
301 }
302 fs::create_dir_all(&rust_project_dir).map_err(|e| BuildError::Io(e.to_string()))?;
303
304 let output = compile_project(entry_path)?;
306
307 let src_dir = rust_project_dir.join("src");
309 fs::create_dir_all(&src_dir).map_err(|e| BuildError::Io(e.to_string()))?;
310
311 let rust_code = output.rust_code.clone();
312
313 if config.lib_mode {
314 let lib_code = strip_main_wrapper(&rust_code);
316 fs::write(src_dir.join("lib.rs"), lib_code).map_err(|e| BuildError::Io(e.to_string()))?;
317 } else {
318 fs::write(src_dir.join("main.rs"), &rust_code).map_err(|e| BuildError::Io(e.to_string()))?;
319 }
320
321 if let Some(ref c_header) = output.c_header {
323 let header_name = format!("{}.h", manifest.package.name);
324 fs::write(rust_project_dir.join(&header_name), c_header)
325 .map_err(|e| BuildError::Io(e.to_string()))?;
326 }
327
328 let resolved_target = config.target.as_deref().map(|t| {
330 if t.eq_ignore_ascii_case("wasm") {
331 "wasm32-unknown-unknown"
332 } else {
333 t
334 }
335 });
336
337 let mut cargo_toml = format!(
339 r#"[package]
340name = "{}"
341version = "{}"
342edition = "2021"
343"#,
344 manifest.package.name, manifest.package.version
345 );
346
347 if config.lib_mode {
349 let _ = writeln!(cargo_toml, "\n[lib]\ncrate-type = [\"cdylib\"]");
350 }
351
352 let _ = writeln!(cargo_toml, "\n[dependencies]");
353 let _ = writeln!(cargo_toml, "logicaffeine-data = {{ path = \"./crates/logicaffeine_data\" }}");
354 let _ = writeln!(cargo_toml, "logicaffeine-system = {{ path = \"./crates/logicaffeine_system\", features = [\"full\"] }}");
355 let _ = writeln!(cargo_toml, "tokio = {{ version = \"1\", features = [\"rt-multi-thread\", \"macros\"] }}");
356
357 let mut has_wasm_bindgen = false;
359 if let Some(target) = resolved_target {
360 if target.starts_with("wasm32") {
361 let _ = writeln!(cargo_toml, "wasm-bindgen = \"0.2\"");
362 has_wasm_bindgen = true;
363 }
364 }
365
366 for dep in &output.dependencies {
370 if dep.name == "wasm-bindgen" && has_wasm_bindgen {
371 continue; }
373 if !requires_component_is_safe(&dep.name)
374 || !requires_component_is_safe(&dep.version)
375 || dep.features.iter().any(|f| !requires_component_is_safe(f))
376 {
377 return Err(BuildError::Compile(CompileError::Io(format!(
378 "`## Requires` declaration for '{}' contains characters that are not valid in a Cargo manifest",
379 dep.name
380 ))));
381 }
382 if dep.features.is_empty() {
383 let _ = writeln!(cargo_toml, "{} = \"{}\"", dep.name, dep.version);
384 } else {
385 let feats = dep.features.iter()
386 .map(|f| format!("\"{}\"", f))
387 .collect::<Vec<_>>()
388 .join(", ");
389 let _ = writeln!(
390 cargo_toml,
391 "{} = {{ version = \"{}\", features = [{}] }}",
392 dep.name, dep.version, feats
393 );
394 }
395 }
396
397 let _ = writeln!(cargo_toml, "\n[profile.release]\nlto = true\nopt-level = 3\ncodegen-units = 1\npanic = \"abort\"\nstrip = true");
399
400 fs::write(rust_project_dir.join("Cargo.toml"), &cargo_toml)
401 .map_err(|e| BuildError::Io(e.to_string()))?;
402
403 if config.release && resolved_target.is_none() {
406 let cargo_config_dir = rust_project_dir.join(".cargo");
407 fs::create_dir_all(&cargo_config_dir)
408 .map_err(|e| BuildError::Io(e.to_string()))?;
409 fs::write(
410 cargo_config_dir.join("config.toml"),
411 "[build]\nrustflags = [\"-C\", \"target-cpu=native\"]\n",
412 )
413 .map_err(|e| BuildError::Io(e.to_string()))?;
414 }
415
416 copy_runtime_crates(&rust_project_dir)?;
418
419 crate::ui::phase("Building", "generated Rust (cargo)");
423 let mut cmd = Command::new("cargo");
424 cmd.arg("build").current_dir(&rust_project_dir);
425 if config.release {
426 cmd.arg("--release");
427 }
428 if let Some(target) = resolved_target {
429 cmd.arg("--target").arg(target);
430 }
431 let color_arg = match anstream::AutoStream::choice(&std::io::stderr()) {
434 anstream::ColorChoice::Never => "never",
435 _ => "always",
436 };
437 cmd.arg("--color").arg(color_arg);
438 if crate::ui::is_quiet() {
439 cmd.arg("--quiet");
440 } else if crate::ui::verbosity() > 0 {
441 cmd.arg("--verbose");
442 }
443 cmd.stdout(std::process::Stdio::inherit());
444 cmd.stderr(std::process::Stdio::piped());
445
446 let mut child = cmd.spawn().map_err(|e| {
447 if e.kind() == std::io::ErrorKind::NotFound {
448 BuildError::Toolchain(
449 "cargo was not found on PATH — a Rust toolchain is required for `largo build`"
450 .to_string(),
451 )
452 } else {
453 BuildError::Io(e.to_string())
454 }
455 })?;
456
457 const TAIL_CAP: usize = 64 * 1024;
460 let mut child_err = child.stderr.take().expect("stderr was piped");
461 let tee = std::thread::spawn(move || {
462 use std::io::{Read, Write};
463 let mut tail: Vec<u8> = Vec::new();
464 let mut buf = [0u8; 8192];
465 let mut err_out = std::io::stderr();
466 loop {
467 match child_err.read(&mut buf) {
468 Ok(0) | Err(_) => break,
469 Ok(n) => {
470 let _ = err_out.write_all(&buf[..n]);
471 let _ = err_out.flush();
472 tail.extend_from_slice(&buf[..n]);
473 if tail.len() > TAIL_CAP {
474 let cut = tail.len() - TAIL_CAP;
475 tail.drain(..cut);
476 }
477 }
478 }
479 }
480 tail
481 });
482
483 let status = child.wait().map_err(|e| BuildError::Io(e.to_string()))?;
484 let tail_bytes = tee.join().unwrap_or_default();
485
486 if !status.success() {
487 let tail = String::from_utf8_lossy(&tail_bytes).into_owned();
488 return Err(BuildError::Cargo(CargoFailure {
489 kind: classify_cargo_failure(&tail),
490 tail,
491 project_dir: rust_project_dir,
492 }));
493 }
494
495 crate::ui::phase(
496 "Finished",
497 format!(
498 "{} profile in {:.1}s",
499 if config.release { "release" } else { "dev" },
500 started.elapsed().as_secs_f64()
501 ),
502 );
503
504 let cargo_target_str = if config.release { "release" } else { "debug" };
506 let binary_path = if config.lib_mode {
507 let lib_name = format!("lib{}", manifest.package.name.replace('-', "_"));
509 let ext = if cfg!(target_os = "macos") { "dylib" } else { "so" };
510 if let Some(target) = resolved_target {
511 rust_project_dir
512 .join("target")
513 .join(target)
514 .join(cargo_target_str)
515 .join(format!("{}.{}", lib_name, ext))
516 } else {
517 rust_project_dir
518 .join("target")
519 .join(cargo_target_str)
520 .join(format!("{}.{}", lib_name, ext))
521 }
522 } else {
523 let binary_name = if cfg!(windows) {
524 format!("{}.exe", manifest.package.name)
525 } else {
526 manifest.package.name.clone()
527 };
528 if let Some(target) = resolved_target {
529 rust_project_dir
530 .join("target")
531 .join(target)
532 .join(cargo_target_str)
533 .join(&binary_name)
534 } else {
535 rust_project_dir
536 .join("target")
537 .join(cargo_target_str)
538 .join(&binary_name)
539 }
540 };
541
542 if let Some(ref _c_header) = output.c_header {
544 let header_name = format!("{}.h", manifest.package.name);
545 let src_header = rust_project_dir.join(&header_name);
546 if src_header.exists() {
547 if let Some(parent) = binary_path.parent() {
548 let _ = fs::copy(&src_header, parent.join(&header_name));
549 }
550 }
551 }
552
553 Ok(BuildResult {
554 target_dir: build_dir,
555 binary_path,
556 })
557}
558
559fn strip_main_wrapper(code: &str) -> String {
562 if let Some(main_pos) = code.find("fn main() {") {
564 let before_main = &code[..main_pos];
565 let after_opening = &code[main_pos + "fn main() {".len()..];
567 if let Some(close_pos) = after_opening.rfind('}') {
568 let main_body = &after_opening[..close_pos];
569 let dedented: Vec<&str> = main_body.lines()
571 .map(|line| line.strip_prefix(" ").unwrap_or(line))
572 .collect();
573 format!("{}\n{}", before_main.trim_end(), dedented.join("\n"))
574 } else {
575 before_main.to_string()
576 }
577 } else {
578 code.to_string()
579 }
580}
581
582pub fn run(build_result: &BuildResult, args: &[String]) -> Result<i32, BuildError> {
599 let mut child = Command::new(&build_result.binary_path)
600 .args(args)
601 .spawn()
602 .map_err(|e| BuildError::Io(e.to_string()))?;
603
604 let status = child.wait().map_err(|e| BuildError::Io(e.to_string()))?;
605
606 Ok(status.code().unwrap_or(1))
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612 use tempfile::tempdir;
613
614 #[test]
615 fn requires_components_reject_toml_structure() {
616 assert!(requires_component_is_safe("itoa"));
617 assert!(requires_component_is_safe("1.0.11"));
618 assert!(requires_component_is_safe(">=1.2, <2.0"));
619 assert!(requires_component_is_safe("wasm-bindgen"));
620 assert!(!requires_component_is_safe("evil\"\n[patch.crates-io]"));
621 assert!(!requires_component_is_safe("x = { path"));
622 assert!(!requires_component_is_safe(""));
623 }
624
625 #[test]
626 fn classify_missing_package_is_dependency_resolution() {
627 let stderr = " Updating crates.io index\n\
628 error: no matching package named `nonexistent-xyz` found\n\
629 location searched: registry `crates-io`\n\
630 required by package `demo v0.1.0`\n";
631 assert_eq!(
632 classify_cargo_failure(stderr),
633 CargoFailureKind::DependencyResolution
634 );
635 }
636
637 #[test]
638 fn classify_version_selection_is_dependency_resolution() {
639 let stderr = "error: failed to select a version for the requirement `itoa = \"^99\"`\n\
640 candidate versions found which didn't match: 1.0.11, 1.0.10\n\
641 location searched: crates.io index\n";
642 assert_eq!(
643 classify_cargo_failure(stderr),
644 CargoFailureKind::DependencyResolution
645 );
646 }
647
648 #[test]
649 fn classify_network_fetch_is_dependency_resolution() {
650 let stderr = "error: failed to get `itoa` as a dependency of package `demo v0.1.0`\n\
651 Caused by:\n failed to fetch `https://github.com/rust-lang/crates.io-index`\n";
652 assert_eq!(
653 classify_cargo_failure(stderr),
654 CargoFailureKind::DependencyResolution
655 );
656 }
657
658 #[test]
659 fn classify_rustc_error_is_generated_code() {
660 let stderr = " Compiling demo v0.1.0\n\
661 error[E0425]: cannot find value `undefined_x` in this scope\n\
662 --> src/main.rs:10:5\n\
663 error: could not compile `demo` (bin \"demo\") due to 1 previous error\n";
664 assert_eq!(classify_cargo_failure(stderr), CargoFailureKind::GeneratedCode);
665 }
666
667 #[test]
668 fn classify_unknown_output_defaults_to_generated_code() {
669 assert_eq!(
670 classify_cargo_failure("something exploded"),
671 CargoFailureKind::GeneratedCode
672 );
673 }
674
675 #[test]
676 fn find_project_root_finds_largo_toml() {
677 let temp = tempdir().unwrap();
678 let sub = temp.path().join("a/b/c");
679 fs::create_dir_all(&sub).unwrap();
680 fs::write(temp.path().join("Largo.toml"), "[package]\nname=\"test\"\n").unwrap();
681
682 let found = find_project_root(&sub);
683 assert!(found.is_some());
684 assert_eq!(found.unwrap(), temp.path());
685 }
686
687 #[test]
688 fn find_project_root_returns_none_if_not_found() {
689 let temp = tempdir().unwrap();
690 let found = find_project_root(temp.path());
691 assert!(found.is_none());
692 }
693
694 #[test]
695 fn strip_main_wrapper_extracts_body() {
696 let code = r#"use logicaffeine_data::*;
697
698fn add(a: i64, b: i64) -> i64 {
699 a + b
700}
701
702fn main() {
703 let x = add(1, 2);
704 println!("{}", x);
705}"#;
706 let result = strip_main_wrapper(code);
707 assert!(result.contains("fn add(a: i64, b: i64) -> i64"));
708 assert!(result.contains("let x = add(1, 2);"));
709 assert!(result.contains("println!(\"{}\", x);"));
710 assert!(!result.contains("fn main()"));
711 }
712
713 #[test]
714 fn strip_main_wrapper_preserves_imports() {
715 let code = "use logicaffeine_data::*;\nuse logicaffeine_system::*;\n\nfn main() {\n println!(\"hello\");\n}\n";
716 let result = strip_main_wrapper(code);
717 assert!(result.contains("use logicaffeine_data::*;"));
718 assert!(result.contains("use logicaffeine_system::*;"));
719 assert!(result.contains("println!(\"hello\");"));
720 assert!(!result.contains("fn main()"));
721 }
722
723 #[test]
724 fn strip_main_wrapper_no_main_returns_unchanged() {
725 let code = "fn add(a: i64, b: i64) -> i64 { a + b }";
726 let result = strip_main_wrapper(code);
727 assert_eq!(result, code);
728 }
729
730 #[test]
731 fn strip_main_wrapper_dedents_body() {
732 let code = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
733 let result = strip_main_wrapper(code);
734 assert!(result.contains("let x = 1;"));
736 assert!(result.contains("let y = 2;"));
737 for line in result.lines() {
739 if line.contains("let x") || line.contains("let y") {
740 assert!(!line.starts_with(" "), "Line should be dedented: {}", line);
741 }
742 }
743 }
744}