lmrc-cli 0.3.16

CLI tool for scaffolding LMRC Stack infrastructure projects
Documentation
use crate::error::{CliError, Result};
use colored::Colorize;
use lmrc_config_validator::LmrcConfig;
use lmrc_pipeline::steps::BootstrapInitStep;
use lmrc_pipeline::{Pipeline, PipelineContext};
use std::fs;
use std::path::PathBuf;
use toml_edit::{DocumentMut, Item, Table};

pub async fn execute() -> Result<()> {
    println!("{}", "LMRC Stack Bootstrap".bright_blue().bold());
    println!("{}", "=".repeat(60).bright_blue());
    println!();

    // Step 1: Check if we're in an LMRC-generated workspace
    validate_lmrc_workspace()?;

    // Step 2: Check if already bootstrapped
    check_not_bootstrapped()?;

    // Step 3: Check .env.bootstrap file exists
    let env_file = check_env_bootstrap_file()?;

    // Step 4: Load required environment variables
    let (gitlab_url, gitlab_token, gitlab_project) = load_required_env_vars(&env_file)?;

    // Step 5: Load project config
    let config = LmrcConfig::from_file(&PathBuf::from("lmrc.toml"))
        .map_err(|e| CliError::Config(format!("Failed to load lmrc.toml: {}", e)))?;

    println!(
        "{} Starting bootstrap for project: {}",
        "".bright_blue(),
        config.project.name.bright_white()
    );
    println!();

    // Step 6: Run bootstrap initialization
    let bootstrap_step = BootstrapInitStep::new(env_file, gitlab_url, gitlab_token, gitlab_project);

    let ctx = PipelineContext::new(config)
        .map_err(|e| CliError::Pipeline(format!("Failed to create pipeline context: {}", e)))?;

    Pipeline::new(ctx)
        .add_step(bootstrap_step)
        .run()
        .await
        .map_err(|e| CliError::Pipeline(format!("Bootstrap failed: {}", e)))?;

    // Step 7: Mark as bootstrapped in Cargo.toml
    mark_as_bootstrapped()?;

    println!();
    println!("{}", "=".repeat(60).bright_green());
    println!(
        "{}",
        "Bootstrap completed successfully!".bright_green().bold()
    );
    println!("{}", "=".repeat(60).bright_green());
    println!();
    println!("Your project is now initialized and pushed to GitLab.");
    println!(
        "The {} field has been added to Cargo.toml to prevent re-running bootstrap.",
        "package.metadata.lmrc.bootstrapped".bright_white()
    );
    println!();

    Ok(())
}

/// Validates that we're in an LMRC-generated workspace
fn validate_lmrc_workspace() -> Result<()> {
    println!("{} Validating LMRC workspace...", "".bright_blue());

    // Check for lmrc.toml
    if !PathBuf::from("lmrc.toml").exists() {
        return Err(CliError::Config(
            "lmrc.toml not found. This command must be run from an LMRC-generated project root."
                .to_string(),
        ));
    }

    // Check for Cargo.toml
    if !PathBuf::from("Cargo.toml").exists() {
        return Err(CliError::Config(
            "Cargo.toml not found. This command must be run from an LMRC-generated project root."
                .to_string(),
        ));
    }

    // Check for infra/pipeline directory
    if !PathBuf::from("infra/pipeline").exists() {
        return Err(CliError::Config(
            "infra/pipeline directory not found. This doesn't appear to be an LMRC-generated project."
                .to_string(),
        ));
    }

    // Check if Cargo.toml has workspace
    let cargo_toml_content = fs::read_to_string("Cargo.toml")
        .map_err(|e| CliError::IoError(format!("Failed to read Cargo.toml: {}", e)))?;

    let doc = cargo_toml_content
        .parse::<DocumentMut>()
        .map_err(|e| CliError::Config(format!("Failed to parse Cargo.toml: {}", e)))?;

    if !doc.contains_key("workspace") {
        return Err(CliError::Config(
            "Cargo.toml doesn't have [workspace] section. This doesn't appear to be an LMRC-generated project."
                .to_string(),
        ));
    }

    println!("  {} Valid LMRC workspace detected", "".bright_green());
    Ok(())
}

/// Checks that the project hasn't been bootstrapped yet
fn check_not_bootstrapped() -> Result<()> {
    println!("{} Checking bootstrap status...", "".bright_blue());

    let cargo_toml_content = fs::read_to_string("Cargo.toml")
        .map_err(|e| CliError::IoError(format!("Failed to read Cargo.toml: {}", e)))?;

    let doc = cargo_toml_content
        .parse::<DocumentMut>()
        .map_err(|e| CliError::Config(format!("Failed to parse Cargo.toml: {}", e)))?;

    // Check for package.metadata.lmrc.bootstrapped
    if let Some(package) = doc.get("package")
        && let Some(metadata) = package.get("metadata")
        && let Some(lmrc) = metadata.get("lmrc")
        && let Some(bootstrapped) = lmrc.get("bootstrapped")
        && bootstrapped.as_bool() == Some(true)
    {
        return Err(CliError::Config(
            "Project has already been bootstrapped. The 'package.metadata.lmrc.bootstrapped' field is set to true in Cargo.toml.\n\
            If you need to re-run bootstrap, manually remove this field from Cargo.toml."
                .to_string(),
        ));
    }

    println!("  {} Project not yet bootstrapped", "".bright_green());
    Ok(())
}

/// Checks that .env.bootstrap file exists
fn check_env_bootstrap_file() -> Result<PathBuf> {
    println!("{} Checking for .env.bootstrap file...", "".bright_blue());

    let env_file = PathBuf::from(".env.bootstrap");

    if !env_file.exists() {
        return Err(CliError::Config(
            ".env.bootstrap file not found.\n\n\
            Please create a .env.bootstrap file with your CI/CD variables:\n\n\
            # Required variables:\n\
            GITLAB_URL=https://gitlab.com\n\
            GITLAB_TOKEN=your-gitlab-token\n\
            GITLAB_PROJECT=your-org/your-project\n\n\
            # Infrastructure tokens:\n\
            HETZNER_API_TOKEN=your-hetzner-token\n\
            CLOUDFLARE_API_TOKEN=your-cloudflare-token\n\n\
            # Add other variables as needed...\n"
                .to_string(),
        ));
    }

    println!("  {} Found .env.bootstrap file", "".bright_green());
    Ok(env_file)
}

/// Loads required environment variables from .env.bootstrap
fn load_required_env_vars(env_file: &PathBuf) -> Result<(String, String, String)> {
    println!(
        "{} Loading required variables from .env.bootstrap...",
        "".bright_blue()
    );

    // Read .env.bootstrap file
    let content = fs::read_to_string(env_file)
        .map_err(|e| CliError::IoError(format!("Failed to read .env.bootstrap: {}", e)))?;

    let mut gitlab_url = None;
    let mut gitlab_token = None;
    let mut gitlab_project = None;

    for line in content.lines() {
        let line = line.trim();

        // Skip empty lines and comments
        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        // Parse KEY=VALUE format
        if let Some((key, value)) = line.split_once('=') {
            let key = key.trim();
            let value = value.trim();

            // Remove quotes from value if present
            let value = if (value.starts_with('"') && value.ends_with('"'))
                || (value.starts_with('\'') && value.ends_with('\''))
            {
                &value[1..value.len() - 1]
            } else {
                value
            };

            match key {
                "GITLAB_URL" => gitlab_url = Some(value.to_string()),
                "GITLAB_TOKEN" => gitlab_token = Some(value.to_string()),
                "GITLAB_PROJECT" => gitlab_project = Some(value.to_string()),
                _ => {}
            }
        }
    }

    // Validate required variables
    let gitlab_url = gitlab_url
        .ok_or_else(|| CliError::Config("GITLAB_URL not found in .env.bootstrap".to_string()))?;

    let gitlab_token = gitlab_token
        .ok_or_else(|| CliError::Config("GITLAB_TOKEN not found in .env.bootstrap".to_string()))?;

    let gitlab_project = gitlab_project.ok_or_else(|| {
        CliError::Config("GITLAB_PROJECT not found in .env.bootstrap".to_string())
    })?;

    println!(
        "  {} GITLAB_URL: {}",
        "".bright_green(),
        gitlab_url.bright_white()
    );
    println!(
        "  {} GITLAB_PROJECT: {}",
        "".bright_green(),
        gitlab_project.bright_white()
    );
    println!(
        "  {} GITLAB_TOKEN: {}",
        "".bright_green(),
        "***hidden***".dimmed()
    );

    Ok((gitlab_url, gitlab_token, gitlab_project))
}

/// Marks the project as bootstrapped in Cargo.toml
fn mark_as_bootstrapped() -> Result<()> {
    println!("{} Marking project as bootstrapped...", "".bright_blue());

    let cargo_toml_path = PathBuf::from("Cargo.toml");
    let cargo_toml_content = fs::read_to_string(&cargo_toml_path)
        .map_err(|e| CliError::IoError(format!("Failed to read Cargo.toml: {}", e)))?;

    let mut doc = cargo_toml_content
        .parse::<DocumentMut>()
        .map_err(|e| CliError::Config(format!("Failed to parse Cargo.toml: {}", e)))?;

    // Get or create package.metadata.lmrc.bootstrapped
    if !doc.contains_key("package") {
        doc["package"] = Item::Table(Table::new());
    }

    let package = doc["package"].as_table_mut().ok_or_else(|| {
        CliError::Config("Invalid Cargo.toml: package is not a table".to_string())
    })?;

    if !package.contains_key("metadata") {
        package["metadata"] = Item::Table(Table::new());
    }

    let metadata = package["metadata"].as_table_mut().ok_or_else(|| {
        CliError::Config("Invalid Cargo.toml: package.metadata is not a table".to_string())
    })?;

    if !metadata.contains_key("lmrc") {
        metadata["lmrc"] = Item::Table(Table::new());
    }

    let lmrc = metadata["lmrc"].as_table_mut().ok_or_else(|| {
        CliError::Config("Invalid Cargo.toml: package.metadata.lmrc is not a table".to_string())
    })?;

    lmrc["bootstrapped"] = Item::Value(toml_edit::Value::Boolean(toml_edit::Formatted::new(true)));

    // Write back to Cargo.toml
    fs::write(&cargo_toml_path, doc.to_string())
        .map_err(|e| CliError::IoError(format!("Failed to write Cargo.toml: {}", e)))?;

    println!(
        "  {} Added package.metadata.lmrc.bootstrapped = true to Cargo.toml",
        "".bright_green()
    );

    Ok(())
}