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    // Append user-declared dependencies from ## Requires blocks
280    for dep in &output.dependencies {
281        if dep.name == "wasm-bindgen" && has_wasm_bindgen {
282            continue; // Already injected
283        }
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
303    copy_runtime_crates(&rust_project_dir)?;
304
305    // Run cargo build
306    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    // Determine binary/library path
323    let cargo_target_str = if config.release { "release" } else { "debug" };
324    let binary_path = if config.lib_mode {
325        // Library output
326        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    // Universal ABI: Copy .h file to the same directory as the binary/library
361    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
377/// Strip the `fn main() { ... }` wrapper from generated code for library mode.
378/// Keeps everything before `fn main()` (imports, types, functions) intact.
379fn strip_main_wrapper(code: &str) -> String {
380    // Find "fn main() {" and extract content before it
381    if let Some(main_pos) = code.find("fn main() {") {
382        let before_main = &code[..main_pos];
383        // Extract the body of main (between the opening { and closing })
384        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            // Dedent main body
388            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
400/// Execute a built LOGOS project.
401///
402/// Spawns the compiled binary and waits for it to complete.
403/// Returns the process exit code.
404///
405/// # Arguments
406///
407/// * `build_result` - Result from a previous [`build`] call
408///
409/// # Returns
410///
411/// The exit code of the process (0 for success, non-zero for failure).
412///
413/// # Errors
414///
415/// Returns [`BuildError::Io`] if the process cannot be spawned.
416pub 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        // Body lines should be dedented by 4 spaces
492        assert!(result.contains("let x = 1;"));
493        assert!(result.contains("let y = 2;"));
494        // Should not have leading 4-space indent
495        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}