oxidite-cli 2.3.1

CLI tool for the Oxidite web framework
Documentation
use std::env;
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use colored::Colorize;

/// Find the project root by searching for `oxidite.toml` or `Cargo.toml`
fn find_project_root() -> Option<PathBuf> {
    let mut current = std::env::current_dir().ok()?;
    loop {
        if current.join("oxidite.toml").exists() || current.join("Cargo.toml").exists() {
            return Some(current);
        }
        if !current.pop() {
            return None;
        }
    }
}

/// Load `.env` from the project root, if present.
/// Skipped when `OXIDITE_SKIP_DOTENV` is set.
fn load_dotenv() {
    if env::var("OXIDITE_SKIP_DOTENV").is_err() {
        if let Some(root) = find_project_root() {
            let env_path = root.join(".env");
            if env_path.exists() {
                let _ = dotenv::from_path(&env_path);
                return;
            }
        }
        let _ = dotenv::dotenv();
    }
}

/// Run the interactive Oxidite console (tinker).
///
/// This works by generating a temporary Rust binary (`src/bin/_tinker.rs`)
/// that imports the project's crate (if it has a `src/lib.rs`), compiling
/// and running it for each snippet the developer enters.  Pressing Ctrl-D
/// (or typing `exit`) ends the session and cleans up the temporary file.
pub fn run_tinker() -> Result<(), Box<dyn std::error::Error>> {
    // Load .env so all variables are available
    load_dotenv();

    // Ensure we're inside a Cargo project
    if !Path::new("Cargo.toml").exists() {
        return Err("Not inside a Cargo project. Run this command from your project root.".into());
    }

    // Read the crate name from Cargo.toml
    let cargo_toml = fs::read_to_string("Cargo.toml")?;
    let crate_name = extract_crate_name(&cargo_toml)
        .ok_or("Could not determine crate name from Cargo.toml")?;
    let crate_ident = crate_name.replace('-', "_");

    // Determine if the project has a library (src/lib.rs) or is binary-only
    let has_lib = Path::new("src/lib.rs").exists();

    // Ensure src/bin/ exists
    fs::create_dir_all("src/bin")?;

    let tinker_path = Path::new("src/bin/_tinker.rs");

    println!("๐Ÿงช Oxidite Tinker v2.3.1");
    if has_lib {
        println!("Library crate detected: using `{crate_ident}::*` imports.");
    } else {
        println!("Binary-only crate detected: only std/oxidite prelude available.");
    }
    println!("Type Rust expressions to evaluate them in your project's context.");
    println!("Type `help` or `?` for a guide, and `exit` or Ctrl-D to quit.\n");

    let stdin = io::stdin();
    let mut lines = stdin.lock().lines();

    loop {
        print!("oxidite> ");
        io::stdout().flush()?;

        let line = match lines.next() {
            Some(Ok(l)) => l,
            _ => break, // EOF / Ctrl-D
        };

        let trimmed = line.trim();

        if trimmed.is_empty() {
            continue;
        }
        if trimmed == "exit" || trimmed == "quit" {
            break;
        }
        if trimmed == "help" || trimmed == "?" || trimmed == "--help" {
            println!("\n๐Ÿ“– Oxidite Tinker Help Guide:");
            println!("-----------------------------");
            println!("Oxidite Tinker evaluates Rust expressions inside your project's context.\n");
            if has_lib {
                println!("Your project has a library crate (src/lib.rs).");
                println!("All public items from your crate are pre-imported.\n");
            } else {
                println!("Your project is a binary-only crate (no src/lib.rs).");
                println!("Only the Oxidite prelude and standard items are available.\n");
            }
            println!("Commands:");
            println!("  help, ?         Show this guide");
            println!("  clear           Clear the terminal screen");
            println!("  exit, quit      Exit the tinker session\n");
            println!("Examples:");
            println!("  1 + 2");
            println!("  Config::load()");
            println!("  Project::all(&db).await\n");
            continue;
        }
        if trimmed == "clear" {
            print!("\x1B[2J\x1B[1H");
            io::stdout().flush()?;
            continue;
        }

        // Build a small main() that runs the snippet
        let code = if has_lib {
            format!(
                r#"// Auto-generated by `oxidite tinker`. Do not commit.
use {crate_ident}::*;

#[tokio::main]
async fn main() {{
    let __result = {{ {snippet} }};
    println!("{{:?}}", __result);
}}
"#,
                snippet = trimmed,
            )
        } else {
            // Binary-only crate: only import oxidite prelude
            // Don't import sub-crates directly as they may not be dependencies
            format!(
                r#"// Auto-generated by `oxidite tinker`. Do not commit.
use oxidite::prelude::*;

#[tokio::main]
async fn main() {{
    let __result = {{ {snippet} }};
    println!("{{:?}}", __result);
}}
"#,
                snippet = trimmed,
            )
        };

        fs::write(tinker_path, &code)?;

        // Compile and run
        print!("{}", "  โš™๏ธ  Evaluating...".green());
        io::stdout().flush()?;

        let output = Command::new("cargo")
            .args(["run", "--bin", "_tinker", "--quiet"])
            // Inherit the environment that already has .env variables loaded
            .envs(std::env::vars())
            .output();

        // Clear the "Evaluating..." loader line
        print!("\r\x1B[K");
        io::stdout().flush()?;

        match output {
            Ok(out) => {
                if !out.status.success() {
                    println!("{}", "  โŒ Evaluation failed:".red().bold());
                    let stderr = String::from_utf8_lossy(&out.stderr);
                    for line in stderr.lines() {
                        if line.trim_start().starts_with("Compiling")
                            || line.trim_start().starts_with("Finished")
                            || line.trim_start().starts_with("Running")
                        {
                            continue;
                        }
                        eprintln!("{}", line.red());
                    }
                } else {
                    if !out.stdout.is_empty() {
                        io::stdout().write_all(&out.stdout)?;
                    }
                }
            }
            Err(e) => {
                eprintln!("{}", format!("Failed to run snippet: {}", e).red());
            }
        }
    }

    // Cleanup
    if tinker_path.exists() {
        let _ = fs::remove_file(tinker_path);
    }

    println!("\n๐Ÿ‘‹ Goodbye!");
    Ok(())
}

fn extract_crate_name(toml_content: &str) -> Option<String> {
    // Simple parser: find `name = "..."` in [package]
    let mut in_package = false;
    for line in toml_content.lines() {
        let trimmed = line.trim();
        if trimmed == "[package]" {
            in_package = true;
            continue;
        }
        if trimmed.starts_with('[') {
            in_package = false;
            continue;
        }
        if in_package {
            if let Some(rest) = trimmed.strip_prefix("name") {
                let rest = rest.trim_start();
                if let Some(rest) = rest.strip_prefix('=') {
                    let rest = rest.trim();
                    let rest = rest.trim_matches('"');
                    return Some(rest.to_string());
                }
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::extract_crate_name;

    #[test]
    fn extracts_name_from_cargo_toml() {
        let toml = r#"
[package]
name = "my-cool-app"
version = "0.1.0"
"#;
        assert_eq!(extract_crate_name(toml), Some("my-cool-app".to_string()));
    }

    #[test]
    fn returns_none_for_missing_name() {
        assert_eq!(extract_crate_name("[dependencies]"), None);
    }
}