lmrc-cli 0.3.16

CLI tool for scaffolding LMRC Stack infrastructure projects
Documentation
use lmrc_config_validator::LmrcConfig;
use lmrc_toml_writer::WorkspaceToml;
use std::fs;
use std::path::Path;

use crate::error::Result;

// Auto-generated version constants from workspace Cargo.toml at compile time
include!(concat!(env!("OUT_DIR"), "/versions.rs"));

pub fn generate_workspace_toml(project_path: &Path, config: &LmrcConfig) -> Result<()> {
    let mut builder = WorkspaceToml::new().resolver("2");

    // Add pipeline if it exists on disk
    if project_path.join("infra/pipeline/Cargo.toml").exists() {
        builder = builder.member("infra/pipeline");
    }

    // Add bundled infrastructure apps only if they exist on disk
    for app in ["gateway", "infra-api", "infra-migrator", "app-migrator"] {
        if project_path
            .join(format!("apps/{}/Cargo.toml", app))
            .exists()
        {
            builder = builder.member(format!("apps/{}", app));
        }
    }

    // Add all user-defined applications as members
    for app in &config.apps.applications {
        // Skip Gateway and Migrator types as they're now bundled
        if matches!(
            app.app_type,
            Some(lmrc_config_validator::AppType::Gateway)
                | Some(lmrc_config_validator::AppType::Migrator)
        ) {
            continue;
        }
        builder = builder.member(format!("apps/{}", app.name));
    }

    // Set workspace package metadata
    let authors = config
        .infrastructure
        .gitlab
        .as_ref()
        .map(|g| format!("{} Team", g.namespace))
        .unwrap_or_else(|| "LMRC Team".to_string());

    // Build repository URL from GitLab config if available
    let repository = config
        .infrastructure
        .gitlab
        .as_ref()
        .map(|g| format!("{}/{}/{}", g.url, g.namespace, config.project.name))
        .unwrap_or_else(|| "https://example.com/repo".to_string());

    builder = builder
        .workspace_version("0.1.0")
        .workspace_edition("2021")
        .workspace_author(authors)
        .workspace_license("MIT OR Apache-2.0")
        .workspace_repository(repository);

    // Add infrastructure library dependencies (versions auto-generated at compile time)
    builder = builder
        .dependency("lmrc-gitlab", LMRC_GITLAB_VERSION)
        .dependency("lmrc-hetzner", LMRC_HETZNER_VERSION)
        .dependency("lmrc-k3s", LMRC_K3S_VERSION)
        .dependency("lmrc-cloudflare", LMRC_CLOUDFLARE_VERSION)
        .dependency("lmrc-postgres", LMRC_POSTGRES_VERSION)
        .dependency("lmrc-ssh", LMRC_SSH_VERSION)
        .dependency("lmrc-docker", LMRC_DOCKER_VERSION)
        .dependency("lmrc-kubernetes", LMRC_KUBERNETES_VERSION)
        .dependency("lmrc-pipeline", LMRC_PIPELINE_VERSION)
        .dependency("lmrc-http-common", LMRC_HTTP_COMMON_VERSION)
        .dependency("lmrc-config-validator", LMRC_CONFIG_VALIDATOR_VERSION);

    // Add common dependencies
    builder = builder
        .dependency("tokio", r#"{ version = "1.0", features = ["full"] }"#)
        .dependency("serde", r#"{ version = "1.0", features = ["derive"] }"#)
        .dependency("serde_json", "1.0")
        .dependency("thiserror", "2.0")
        .dependency("tracing", "0.1")
        .dependency("tracing-subscriber", "0.3")
        .dependency("clap", r#"{ version = "4.5", features = ["derive"] }"#)
        .dependency("toml", "0.8")
        // HTTP service dependencies
        .dependency("axum", "0.7")
        .dependency("tower", "0.5")
        .dependency("tower-http", r#"{ version = "0.6", features = ["trace", "cors", "request-id", "util"] }"#)
        .dependency("hyper", r#"{ version = "1.0", features = ["full"] }"#)
        .dependency("hyper-util", r#"{ version = "0.1", features = ["tokio"] }"#)
        .dependency("sea-orm", r#"{ version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls", "macros"] }"#)
        .dependency("sea-orm-migration", "1.1")
        .dependency("jsonwebtoken", "9.3")
        .dependency("bcrypt", "0.16")
        .dependency("cookie", "0.18")
        .dependency("chrono", r#"{ version = "0.4", features = ["serde"] }"#)
        .dependency("time", "0.3")
        .dependency("dotenvy", "0.15")
        .dependency("validator", r#"{ version = "0.18", features = ["derive"] }"#)
        .dependency("uuid", r#"{ version = "1.0", features = ["v4"] }"#)
        .dependency("anyhow", "1.0")
        .dependency("async-trait", "0.1")
        .dependency("reqwest", r#"{ version = "0.12", default-features = false, features = ["rustls-tls", "json"] }"#)
   ;

    let workspace_toml = builder.build();

    let cargo_path = project_path.join("Cargo.toml");
    fs::write(&cargo_path, workspace_toml)?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use lmrc_config_validator::*;
    use std::collections::HashMap;
    use tempfile::TempDir;

    fn create_test_config() -> LmrcConfig {
        LmrcConfig {
            project: ProjectConfig {
                name: "test-project".to_string(),
                description: "Test project".to_string(),
            },
            providers: ProviderConfig {
                server: "hetzner".to_string(),
                kubernetes: "k3s".to_string(),
                database: "postgres".to_string(),
                queue: "rabbitmq".to_string(),
                dns: "cloudflare".to_string(),
                git: "gitlab".to_string(),
            },
            apps: AppsConfig {
                applications: vec![
                    ApplicationEntry {
                        name: "app1".to_string(),
                        app_type: Some(lmrc_config_validator::AppType::Api),
                        docker: None,
                        deployment: None,
                    },
                    ApplicationEntry {
                        name: "app2".to_string(),
                        app_type: Some(lmrc_config_validator::AppType::Basic),
                        docker: None,
                        deployment: None,
                    },
                ],
            },
            infrastructure: InfrastructureConfig {
                provider: "hetzner".to_string(),
                network: None,
                servers: vec![ServerGroup {
                    name: "k3s-server".to_string(),
                    role: ServerRole::K3sControl,
                    server_type: "cx11".to_string(),
                    location: "nbg1".to_string(),
                    count: 1,
                    labels: HashMap::new(),
                    ssh_keys: vec![],
                    image: None,
                }],
                k3s: Some(K3sConfig {
                    version: "v1.28.5+k3s1".to_string(),
                    deploy_on: vec!["k3s-server".to_string()],
                    control_plane_servers: vec!["k3s-server".to_string()],
                    worker_servers: vec![],
                    enable_traefik: true,
                    enable_metrics_server: false,
                    server_flags: vec![],
                    agent_flags: vec![],
                }),
                postgres: Some(PostgresConfig {
                    version: "16".to_string(),
                    database_name: "testdb".to_string(),
                    deployment_mode: PostgresDeploymentMode::InCluster,
                    standalone: None,
                    in_cluster: Some(PostgresInClusterConfig {
                        namespace: "default".to_string(),
                        storage_class: "local-path".to_string(),
                        storage_size: "10Gi".to_string(),
                        use_operator: false,
                    }),
                }),
                rabbitmq: None,
                vault: None,
                dns: Some(DnsConfig {
                    provider: "cloudflare".to_string(),
                    domain: "test.example.com".to_string(),
                    records: vec![],
                }),
                gitlab: Some(GitLabConfig {
                    url: "https://gitlab.com".to_string(),
                    namespace: "testuser".to_string(),
                }),
                load_balancer: None,
            },
        }
    }

    #[test]
    fn test_generate_workspace_toml_creates_file() {
        let temp_dir = TempDir::new().unwrap();
        let config = create_test_config();

        let result = generate_workspace_toml(temp_dir.path(), &config);
        assert!(result.is_ok());

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        assert!(cargo_toml.exists());
    }

    #[test]
    fn test_workspace_toml_has_workspace_section() {
        let temp_dir = TempDir::new().unwrap();
        let config = create_test_config();

        generate_workspace_toml(temp_dir.path(), &config).unwrap();

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        let content = fs::read_to_string(cargo_toml).unwrap();

        assert!(content.contains("[workspace]"));
    }

    #[test]
    fn test_workspace_toml_has_correct_resolver() {
        let temp_dir = TempDir::new().unwrap();
        let config = create_test_config();

        generate_workspace_toml(temp_dir.path(), &config).unwrap();

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        let content = fs::read_to_string(cargo_toml).unwrap();

        assert!(content.contains("resolver = \"2\""));
    }

    #[test]
    fn test_workspace_toml_includes_all_app_members() {
        let temp_dir = TempDir::new().unwrap();
        let config = create_test_config();

        generate_workspace_toml(temp_dir.path(), &config).unwrap();

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        let content = fs::read_to_string(cargo_toml).unwrap();

        assert!(content.contains("\"apps/app1\""));
        assert!(content.contains("\"apps/app2\""));
    }

    #[test]
    fn test_workspace_toml_includes_pipeline_member() {
        let temp_dir = TempDir::new().unwrap();
        let config = create_test_config();

        // Create pipeline directory with Cargo.toml so it gets included
        let pipeline_dir = temp_dir.path().join("infra/pipeline");
        fs::create_dir_all(&pipeline_dir).unwrap();
        fs::write(
            pipeline_dir.join("Cargo.toml"),
            "[package]\nname = \"pipeline\"\n",
        )
        .unwrap();

        generate_workspace_toml(temp_dir.path(), &config).unwrap();

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        let content = fs::read_to_string(cargo_toml).unwrap();

        assert!(content.contains("\"infra/pipeline\""));
    }

    #[test]
    fn test_workspace_toml_includes_lmrc_dependencies() {
        let temp_dir = TempDir::new().unwrap();
        let config = create_test_config();

        generate_workspace_toml(temp_dir.path(), &config).unwrap();

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        let content = fs::read_to_string(cargo_toml).unwrap();

        assert!(content.contains("[workspace.dependencies]"));

        // Check for all lmrc library dependencies
        let lmrc_deps = [
            "lmrc-gitlab",
            "lmrc-hetzner",
            "lmrc-k3s",
            "lmrc-cloudflare",
            "lmrc-postgres",
            "lmrc-ssh",
            "lmrc-docker",
            "lmrc-kubernetes",
            "lmrc-pipeline",
        ];

        for dep in &lmrc_deps {
            assert!(
                content.contains(dep),
                "Missing workspace dependency: {}",
                dep
            );
        }
    }

    #[test]
    fn test_workspace_toml_includes_common_dependencies() {
        let temp_dir = TempDir::new().unwrap();
        let config = create_test_config();

        generate_workspace_toml(temp_dir.path(), &config).unwrap();

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        let content = fs::read_to_string(cargo_toml).unwrap();

        // Check for common dependencies
        let common_deps = [
            "tokio",
            "serde",
            "serde_json",
            "thiserror",
            "tracing",
            "clap",
            "toml",
        ];

        for dep in &common_deps {
            assert!(content.contains(dep), "Missing common dependency: {}", dep);
        }
    }

    #[test]
    fn test_workspace_toml_with_single_app() {
        let temp_dir = TempDir::new().unwrap();
        let mut config = create_test_config();
        config.apps.applications = vec![ApplicationEntry {
            name: "single-app".to_string(),
            app_type: Some(lmrc_config_validator::AppType::Api),
            docker: None,
            deployment: None,
        }];

        generate_workspace_toml(temp_dir.path(), &config).unwrap();

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        let content = fs::read_to_string(cargo_toml).unwrap();

        assert!(content.contains("\"apps/single-app\""));
        assert!(!content.contains("\"apps/app1\""));
    }

    #[test]
    fn test_workspace_toml_sets_workspace_metadata() {
        let temp_dir = TempDir::new().unwrap();
        let config = create_test_config();

        generate_workspace_toml(temp_dir.path(), &config).unwrap();

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        let content = fs::read_to_string(cargo_toml).unwrap();

        assert!(content.contains("[workspace.package]"));
        assert!(content.contains("version = \"0.1.0\""));
        assert!(content.contains("edition = \"2021\""));
    }

    #[test]
    fn test_workspace_toml_author_from_gitlab_namespace() {
        let temp_dir = TempDir::new().unwrap();
        let config = create_test_config();

        generate_workspace_toml(temp_dir.path(), &config).unwrap();

        let cargo_toml = temp_dir.path().join("Cargo.toml");
        let content = fs::read_to_string(cargo_toml).unwrap();

        // Should include author based on GitLab namespace
        assert!(content.contains("testuser Team"));
    }
}