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 (classified; the raw output already
89    /// streamed to the user's terminal live).
90    Cargo(CargoFailure),
91    /// Cargo itself was not found on PATH (no Rust toolchain installed).
92    Toolchain(String),
93    /// A required file or directory was not found.
94    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/// What actually went wrong when `cargo build` failed, judged from its
113/// stderr tail.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum CargoFailureKind {
116    /// A dependency could not be resolved or fetched — a `## Requires`
117    /// problem (bad crate name/version, or no network), not a compiler bug.
118    DependencyResolution,
119    /// rustc rejected the generated code — a LOGOS compiler bug, never the
120    /// user's fault.
121    GeneratedCode,
122}
123
124/// A classified `cargo build` failure. The full output already streamed to
125/// the terminal; this carries the verdict plus the retained tail for
126/// programmatic callers.
127#[derive(Debug)]
128pub struct CargoFailure {
129    /// The classification verdict.
130    pub kind: CargoFailureKind,
131    /// The last portion of cargo's stderr (bounded).
132    pub tail: String,
133    /// The generated Cargo project the failure occurred in.
134    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
153/// Whether a `## Requires` crate name / version / feature is safe to write
154/// into the generated Cargo.toml verbatim: strictly alphanumerics plus the
155/// characters cargo specs actually use. Anything else (quotes, newlines,
156/// brackets) could smuggle TOML structure into the manifest.
157pub 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
164/// Judge a failed `cargo build` from its stderr tail.
165///
166/// Dependency-resolution failures (unknown crate, impossible version
167/// requirement, index/network fetch trouble) are the user-actionable class;
168/// everything else — above all rustc errors in the generated code — is a
169/// compiler bug on our side and is framed that way.
170pub 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
198/// Find the project root by walking up the directory tree.
199///
200/// Searches for a `Largo.toml` file starting from `start` and moving
201/// up through parent directories. Returns the directory containing
202/// the manifest, or `None` if no manifest is found.
203///
204/// # Arguments
205///
206/// * `start` - Starting path (can be a file or directory)
207///
208/// # Example
209///
210/// ```no_run
211/// use std::path::Path;
212/// use logicaffeine_cli::project::build::find_project_root;
213///
214/// // Find project root from a subdirectory
215/// let root = find_project_root(Path::new("/projects/myapp/src/lib.lg"));
216/// assert_eq!(root, Some("/projects/myapp".into()));
217/// ```
218pub 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
235/// Build a LOGOS project.
236///
237/// Compiles the project specified in `config` through the full build pipeline:
238/// 1. Load and validate the manifest
239/// 2. Compile LOGOS source to Rust
240/// 3. Generate a Cargo project with runtime dependencies
241/// 4. Run `cargo build`
242///
243/// The entry point is determined from the manifest's `package.entry` field,
244/// with a `.md` extension fallback if the `.lg` file doesn't exist.
245///
246/// # Errors
247///
248/// Returns an error if:
249/// - The manifest cannot be loaded
250/// - The entry point file doesn't exist
251/// - LOGOS compilation fails
252/// - Cargo build fails
253pub fn build(config: BuildConfig) -> Result<BuildResult, BuildError> {
254    // Load manifest
255    let manifest = Manifest::load(&config.project_dir)?;
256
257    // Resolve entry point (supports .lg and .md)
258    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    // Try .md fallback if .lg not found
264    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    // Create target directory structure
290    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    // Clean and recreate build directory
299    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    // Compile LOGOS to Rust using Phase 36 compile_project
305    let output = compile_project(entry_path)?;
306
307    // Write generated Rust code
308    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        // Library mode: strip fn main() wrapper, write to lib.rs
315        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    // Universal ABI: Write C header alongside generated code if present
322    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    // Resolve target triple (expand "wasm" shorthand)
329    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    // Write Cargo.toml for the generated project
338    let mut cargo_toml = format!(
339        r#"[package]
340name = "{}"
341version = "{}"
342edition = "2021"
343"#,
344        manifest.package.name, manifest.package.version
345    );
346
347    // Library mode: add [lib] section with cdylib crate type
348    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    // Auto-inject wasm-bindgen when targeting wasm32
358    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    // Append user-declared dependencies from ## Requires blocks — these must
367    // stay inside [dependencies], before any later section header, and every
368    // component must be manifest-safe (no TOML structure smuggled in).
369    for dep in &output.dependencies {
370        if dep.name == "wasm-bindgen" && has_wasm_bindgen {
371            continue; // Already injected
372        }
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    // Release profile: maximize optimization for compiled programs
398    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    // Emit .cargo/config.toml with target-cpu=native for release builds.
404    // Only when not cross-compiling (target-cpu=native refers to the host CPU).
405    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
417    copy_runtime_crates(&rust_project_dir)?;
418
419    // Run cargo build: stdout inherits, stderr streams through a tee so the
420    // user watches cargo's progress live while we retain a bounded tail for
421    // failure classification.
422    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    // Cargo sees a pipe on stderr and would disable its own color; pass the
432    // resolved choice explicitly so cargo and largo agree.
433    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    // Tee thread: copy raw bytes (cargo redraws progress with `\r`, so no
458    // line buffering) to our stderr while retaining the last TAIL_CAP bytes.
459    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    // Determine binary/library path
505    let cargo_target_str = if config.release { "release" } else { "debug" };
506    let binary_path = if config.lib_mode {
507        // Library output
508        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    // Universal ABI: Copy .h file to the same directory as the binary/library
543    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
559/// Strip the `fn main() { ... }` wrapper from generated code for library mode.
560/// Keeps everything before `fn main()` (imports, types, functions) intact.
561fn strip_main_wrapper(code: &str) -> String {
562    // Find "fn main() {" and extract content before it
563    if let Some(main_pos) = code.find("fn main() {") {
564        let before_main = &code[..main_pos];
565        // Extract the body of main (between the opening { and closing })
566        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            // Dedent main body
570            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
582/// Execute a built LOGOS project.
583///
584/// Spawns the compiled binary and waits for it to complete.
585/// Returns the process exit code.
586///
587/// # Arguments
588///
589/// * `build_result` - Result from a previous [`build`] call
590///
591/// # Returns
592///
593/// The exit code of the process (0 for success, non-zero for failure).
594///
595/// # Errors
596///
597/// Returns [`BuildError::Io`] if the process cannot be spawned.
598pub 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        // Body lines should be dedented by 4 spaces
735        assert!(result.contains("let x = 1;"));
736        assert!(result.contains("let y = 2;"));
737        // Should not have leading 4-space indent
738        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}