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