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::fs;
25use std::path::{Path, PathBuf};
26use std::process::Command;
27
28use crate::compile::compile_project;
29use logicaffeine_compile::compile::{copy_runtime_crates, CompileError};
30
31use super::manifest::{Manifest, ManifestError};
32
33/// Configuration for a build operation.
34///
35/// Specifies the project location and build mode (debug/release).
36///
37/// # Example
38///
39/// ```no_run
40/// use std::path::PathBuf;
41/// use logicaffeine_cli::project::build::{BuildConfig, build};
42///
43/// let config = BuildConfig {
44///     project_dir: PathBuf::from("my_project"),
45///     release: false,
46/// };
47///
48/// let result = build(config)?;
49/// println!("Built: {}", result.binary_path.display());
50/// # Ok::<(), Box<dyn std::error::Error>>(())
51/// ```
52pub struct BuildConfig {
53    /// Root directory of the LOGOS project (contains `Largo.toml`).
54    pub project_dir: PathBuf,
55    /// If `true`, build with optimizations (`cargo build --release`).
56    pub release: bool,
57}
58
59/// Result of a successful build operation.
60///
61/// Contains paths to the build outputs, used by subsequent commands
62/// like [`run`] to execute the compiled binary.
63#[derive(Debug)]
64pub struct BuildResult {
65    /// Directory containing build artifacts (`target/debug` or `target/release`).
66    pub target_dir: PathBuf,
67    /// Path to the compiled executable.
68    pub binary_path: PathBuf,
69}
70
71/// Errors that can occur during the build process.
72#[derive(Debug)]
73pub enum BuildError {
74    /// Failed to load or parse the project manifest.
75    Manifest(ManifestError),
76    /// LOGOS-to-Rust compilation failed.
77    Compile(CompileError),
78    /// File system operation failed.
79    Io(String),
80    /// Cargo build command failed.
81    Cargo(String),
82    /// A required file or directory was not found.
83    NotFound(String),
84}
85
86impl std::fmt::Display for BuildError {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            BuildError::Manifest(e) => write!(f, "{}", e),
90            BuildError::Compile(e) => write!(f, "{}", e),
91            BuildError::Io(e) => write!(f, "IO error: {}", e),
92            BuildError::Cargo(e) => write!(f, "Cargo error: {}", e),
93            BuildError::NotFound(e) => write!(f, "Not found: {}", e),
94        }
95    }
96}
97
98impl std::error::Error for BuildError {}
99
100impl From<ManifestError> for BuildError {
101    fn from(e: ManifestError) -> Self {
102        BuildError::Manifest(e)
103    }
104}
105
106impl From<CompileError> for BuildError {
107    fn from(e: CompileError) -> Self {
108        BuildError::Compile(e)
109    }
110}
111
112/// Find the project root by walking up the directory tree.
113///
114/// Searches for a `Largo.toml` file starting from `start` and moving
115/// up through parent directories. Returns the directory containing
116/// the manifest, or `None` if no manifest is found.
117///
118/// # Arguments
119///
120/// * `start` - Starting path (can be a file or directory)
121///
122/// # Example
123///
124/// ```no_run
125/// use std::path::Path;
126/// use logicaffeine_cli::project::build::find_project_root;
127///
128/// // Find project root from a subdirectory
129/// let root = find_project_root(Path::new("/projects/myapp/src/lib.lg"));
130/// assert_eq!(root, Some("/projects/myapp".into()));
131/// ```
132pub fn find_project_root(start: &Path) -> Option<PathBuf> {
133    let mut current = if start.is_file() {
134        start.parent()?.to_path_buf()
135    } else {
136        start.to_path_buf()
137    };
138
139    loop {
140        if current.join("Largo.toml").exists() {
141            return Some(current);
142        }
143        if !current.pop() {
144            return None;
145        }
146    }
147}
148
149/// Build a LOGOS project.
150///
151/// Compiles the project specified in `config` through the full build pipeline:
152/// 1. Load and validate the manifest
153/// 2. Compile LOGOS source to Rust
154/// 3. Generate a Cargo project with runtime dependencies
155/// 4. Run `cargo build`
156///
157/// The entry point is determined from the manifest's `package.entry` field,
158/// with a `.md` extension fallback if the `.lg` file doesn't exist.
159///
160/// # Errors
161///
162/// Returns an error if:
163/// - The manifest cannot be loaded
164/// - The entry point file doesn't exist
165/// - LOGOS compilation fails
166/// - Cargo build fails
167pub fn build(config: BuildConfig) -> Result<BuildResult, BuildError> {
168    // Load manifest
169    let manifest = Manifest::load(&config.project_dir)?;
170
171    // Resolve entry point (supports .lg and .md)
172    let entry_path = config.project_dir.join(&manifest.package.entry);
173    if entry_path.exists() {
174        return build_with_entry(&config, &manifest, &entry_path);
175    }
176
177    // Try .md fallback if .lg not found
178    let md_path = entry_path.with_extension("md");
179    if md_path.exists() {
180        return build_with_entry(&config, &manifest, &md_path);
181    }
182
183    Err(BuildError::NotFound(format!(
184        "Entry point not found: {} (also tried .md)",
185        entry_path.display()
186    )))
187}
188
189fn build_with_entry(
190    config: &BuildConfig,
191    manifest: &Manifest,
192    entry_path: &Path,
193) -> Result<BuildResult, BuildError> {
194    // Create target directory structure
195    let target_dir = config.project_dir.join("target");
196    let build_dir = if config.release {
197        target_dir.join("release")
198    } else {
199        target_dir.join("debug")
200    };
201    let rust_project_dir = build_dir.join("build");
202
203    // Clean and recreate build directory
204    if rust_project_dir.exists() {
205        fs::remove_dir_all(&rust_project_dir).map_err(|e| BuildError::Io(e.to_string()))?;
206    }
207    fs::create_dir_all(&rust_project_dir).map_err(|e| BuildError::Io(e.to_string()))?;
208
209    // Compile LOGOS to Rust using Phase 36 compile_project
210    let rust_code = compile_project(entry_path)?;
211
212    // Write generated Rust code
213    let src_dir = rust_project_dir.join("src");
214    fs::create_dir_all(&src_dir).map_err(|e| BuildError::Io(e.to_string()))?;
215
216    let main_rs = format!("use logicaffeine_data::*;\nuse logicaffeine_system::*;\n\n{}", rust_code);
217    fs::write(src_dir.join("main.rs"), main_rs).map_err(|e| BuildError::Io(e.to_string()))?;
218
219    // Write Cargo.toml for the generated project
220    let cargo_toml = format!(
221        r#"[package]
222name = "{}"
223version = "{}"
224edition = "2021"
225
226[dependencies]
227logicaffeine-data = {{ path = "./crates/logicaffeine_data" }}
228logicaffeine-system = {{ path = "./crates/logicaffeine_system", features = ["full"] }}
229tokio = {{ version = "1", features = ["rt-multi-thread", "macros"] }}
230"#,
231        manifest.package.name, manifest.package.version
232    );
233    fs::write(rust_project_dir.join("Cargo.toml"), cargo_toml)
234        .map_err(|e| BuildError::Io(e.to_string()))?;
235
236    // Copy runtime crates
237    copy_runtime_crates(&rust_project_dir)?;
238
239    // Run cargo build
240    let mut cmd = Command::new("cargo");
241    cmd.arg("build").current_dir(&rust_project_dir);
242    if config.release {
243        cmd.arg("--release");
244    }
245
246    let output = cmd.output().map_err(|e| BuildError::Io(e.to_string()))?;
247
248    if !output.status.success() {
249        let stderr = String::from_utf8_lossy(&output.stderr);
250        return Err(BuildError::Cargo(stderr.to_string()));
251    }
252
253    // Determine binary path
254    let binary_name = if cfg!(windows) {
255        format!("{}.exe", manifest.package.name)
256    } else {
257        manifest.package.name.clone()
258    };
259    let cargo_target = if config.release { "release" } else { "debug" };
260    let binary_path = rust_project_dir
261        .join("target")
262        .join(cargo_target)
263        .join(&binary_name);
264
265    Ok(BuildResult {
266        target_dir: build_dir,
267        binary_path,
268    })
269}
270
271/// Execute a built LOGOS project.
272///
273/// Spawns the compiled binary and waits for it to complete.
274/// Returns the process exit code.
275///
276/// # Arguments
277///
278/// * `build_result` - Result from a previous [`build`] call
279///
280/// # Returns
281///
282/// The exit code of the process (0 for success, non-zero for failure).
283///
284/// # Errors
285///
286/// Returns [`BuildError::Io`] if the process cannot be spawned.
287pub fn run(build_result: &BuildResult) -> Result<i32, BuildError> {
288    let mut child = Command::new(&build_result.binary_path)
289        .spawn()
290        .map_err(|e| BuildError::Io(e.to_string()))?;
291
292    let status = child.wait().map_err(|e| BuildError::Io(e.to_string()))?;
293
294    Ok(status.code().unwrap_or(1))
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use tempfile::tempdir;
301
302    #[test]
303    fn find_project_root_finds_largo_toml() {
304        let temp = tempdir().unwrap();
305        let sub = temp.path().join("a/b/c");
306        fs::create_dir_all(&sub).unwrap();
307        fs::write(temp.path().join("Largo.toml"), "[package]\nname=\"test\"\n").unwrap();
308
309        let found = find_project_root(&sub);
310        assert!(found.is_some());
311        assert_eq!(found.unwrap(), temp.path());
312    }
313
314    #[test]
315    fn find_project_root_returns_none_if_not_found() {
316        let temp = tempdir().unwrap();
317        let found = find_project_root(temp.path());
318        assert!(found.is_none());
319    }
320}