Skip to main content

logicaffeine_cli/project/
build.rs

1//! Phase 37: Build Orchestration
2//!
3//! Coordinates the build process for LOGOS projects.
4//!
5//! This module handles the complete build pipeline:
6//! 1. Load the project manifest (`Largo.toml`)
7//! 2. Compile LOGOS source to Rust code
8//! 3. Set up a Cargo project with runtime dependencies
9//! 4. Invoke `cargo build` to produce the final binary
10//!
11//! # Build Directory Structure
12//!
13//! ```text
14//! target/
15//! ├── debug/
16//! │   └── build/           # Generated Cargo project (debug)
17//! │       ├── Cargo.toml
18//! │       ├── src/main.rs  # Generated Rust code
19//! │       └── target/      # Cargo's output
20//! └── release/
21//!     └── build/           # Generated Cargo project (release)
22//! ```
23
24use 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
34/// Configuration for a build operation.
35///
36/// Specifies the project location and build mode (debug/release).
37///
38/// # Example
39///
40/// ```no_run
41/// use std::path::PathBuf;
42/// use logicaffeine_cli::project::build::{BuildConfig, build};
43///
44/// let config = BuildConfig {
45///     project_dir: PathBuf::from("my_project"),
46///     release: false,
47///     lib_mode: false,
48///     target: None,
49/// };
50///
51/// let result = build(config)?;
52/// println!("Built: {}", result.binary_path.display());
53/// # Ok::<(), Box<dyn std::error::Error>>(())
54/// ```
55pub struct BuildConfig {
56    /// Root directory of the LOGOS project (contains `Largo.toml`).
57    pub project_dir: PathBuf,
58    /// If `true`, build with optimizations (`cargo build --release`).
59    pub release: bool,
60    /// If `true`, build as a library (cdylib) instead of a binary.
61    pub lib_mode: bool,
62    /// Target triple for cross-compilation (e.g., "wasm32-unknown-unknown").
63    /// "wasm" is expanded to "wasm32-unknown-unknown".
64    pub target: Option<String>,
65}
66
67/// Result of a successful build operation.
68///
69/// Contains paths to the build outputs, used by subsequent commands
70/// like [`run`] to execute the compiled binary.
71#[derive(Debug)]
72pub struct BuildResult {
73    /// Directory containing build artifacts (`target/debug` or `target/release`).
74    pub target_dir: PathBuf,
75    /// Path to the compiled executable.
76    pub binary_path: PathBuf,
77}
78
79/// Errors that can occur during the build process.
80#[derive(Debug)]
81pub enum BuildError {
82    /// Failed to load or parse the project manifest.
83    Manifest(ManifestError),
84    /// LOGOS-to-Rust compilation failed.
85    Compile(CompileError),
86    /// File system operation failed.
87    Io(String),
88    /// Cargo build command failed.
89    Cargo(String),
90    /// A required file or directory was not found.
91    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
120/// Find the project root by walking up the directory tree.
121///
122/// Searches for a `Largo.toml` file starting from `start` and moving
123/// up through parent directories. Returns the directory containing
124/// the manifest, or `None` if no manifest is found.
125///
126/// # Arguments
127///
128/// * `start` - Starting path (can be a file or directory)
129///
130/// # Example
131///
132/// ```no_run
133/// use std::path::Path;
134/// use logicaffeine_cli::project::build::find_project_root;
135///
136/// // Find project root from a subdirectory
137/// let root = find_project_root(Path::new("/projects/myapp/src/lib.lg"));
138/// assert_eq!(root, Some("/projects/myapp".into()));
139/// ```
140pub 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
157/// Build a LOGOS project.
158///
159/// Compiles the project specified in `config` through the full build pipeline:
160/// 1. Load and validate the manifest
161/// 2. Compile LOGOS source to Rust
162/// 3. Generate a Cargo project with runtime dependencies
163/// 4. Run `cargo build`
164///
165/// The entry point is determined from the manifest's `package.entry` field,
166/// with a `.md` extension fallback if the `.lg` file doesn't exist.
167///
168/// # Errors
169///
170/// Returns an error if:
171/// - The manifest cannot be loaded
172/// - The entry point file doesn't exist
173/// - LOGOS compilation fails
174/// - Cargo build fails
175pub fn build(config: BuildConfig) -> Result<BuildResult, BuildError> {
176    // Load manifest
177    let manifest = Manifest::load(&config.project_dir)?;
178
179    // Resolve entry point (supports .lg and .md)
180    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    // Try .md fallback if .lg not found
186    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    // Create target directory structure
203    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    // Clean and recreate build directory
212    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    // Compile LOGOS to Rust using Phase 36 compile_project
218    let output = compile_project(entry_path)?;
219
220    // Write generated Rust code
221    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        // Library mode: strip fn main() wrapper, write to lib.rs
228        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    // Universal ABI: Write C header alongside generated code if present
235    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    // Resolve target triple (expand "wasm" shorthand)
242    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    // Write Cargo.toml for the generated project
251    let mut cargo_toml = format!(
252        r#"[package]
253name = "{}"
254version = "{}"
255edition = "2021"
256"#,
257        manifest.package.name, manifest.package.version
258    );
259
260    // Library mode: add [lib] section with cdylib crate type
261    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    // Auto-inject wasm-bindgen when targeting wasm32
271    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    // Release profile: enable full LTO for cross-crate inlining
280    let _ = writeln!(cargo_toml, "\n[profile.release]\nlto = true");
281
282    // Append user-declared dependencies from ## Requires blocks
283    for dep in &output.dependencies {
284        if dep.name == "wasm-bindgen" && has_wasm_bindgen {
285            continue; // Already injected
286        }
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
306    copy_runtime_crates(&rust_project_dir)?;
307
308    // Run cargo build
309    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    // Determine binary/library path
326    let cargo_target_str = if config.release { "release" } else { "debug" };
327    let binary_path = if config.lib_mode {
328        // Library output
329        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    // Universal ABI: Copy .h file to the same directory as the binary/library
364    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
380/// Strip the `fn main() { ... }` wrapper from generated code for library mode.
381/// Keeps everything before `fn main()` (imports, types, functions) intact.
382fn strip_main_wrapper(code: &str) -> String {
383    // Find "fn main() {" and extract content before it
384    if let Some(main_pos) = code.find("fn main() {") {
385        let before_main = &code[..main_pos];
386        // Extract the body of main (between the opening { and closing })
387        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            // Dedent main body
391            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
403/// Execute a built LOGOS project.
404///
405/// Spawns the compiled binary and waits for it to complete.
406/// Returns the process exit code.
407///
408/// # Arguments
409///
410/// * `build_result` - Result from a previous [`build`] call
411///
412/// # Returns
413///
414/// The exit code of the process (0 for success, non-zero for failure).
415///
416/// # Errors
417///
418/// Returns [`BuildError::Io`] if the process cannot be spawned.
419pub 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        // Body lines should be dedented by 4 spaces
495        assert!(result.contains("let x = 1;"));
496        assert!(result.contains("let y = 2;"));
497        // Should not have leading 4-space indent
498        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}