stackql-deploy 2.0.9

Infrastructure-as-code framework for declarative cloud resource management using StackQL
// commands/init.rs

//! # Init Command Module
//!
//! This module handles the `init` command, which initializes a new StackQL Deploy project structure.
//! It supports built-in templates for major providers (AWS, Azure, Google) as well as custom templates via URL or file path.
//!
//! ## Features
//! - Initializes project directory structure.
//! - Supports both embedded templates and custom templates.
//! - Fetches templates from URLs or uses built-in ones.
//! - Validates supported providers and applies default providers when necessary.
//!
//! ## Example Usage
//! ```bash
//! ./stackql-deploy init my-project --provider aws
//! ./stackql-deploy init my-project --template https://github.com/user/template-repo
//! ```

use std::collections::HashSet;
use std::fs;
use std::io::Write;
use std::path::Path;

use clap::{Arg, ArgAction, ArgMatches, Command};
use reqwest::blocking::Client;
use reqwest::StatusCode;
use tera::{Context, Tera};

use crate::app::{
    aws_templates, azure_templates, google_templates, DEFAULT_PROVIDER, GITHUB_TEMPLATE_BASE,
    SUPPORTED_PROVIDERS,
};
use crate::utils::display::print_unicode_box;
use crate::{print_error, print_info, print_success};

enum TemplateSource {
    Embedded(String), // Built-in template using one of the supported providers
    Custom(String),   // Custom template path or URL
}

impl TemplateSource {
    fn get_sample_res_name(&self) -> &str {
        match self {
            TemplateSource::Embedded(provider) => match provider.as_str() {
                "google" => "example_vpc",
                "azure" => "example_res_grp",
                "aws" => "example_vpc",
                _ => "example_resource",
            },
            TemplateSource::Custom(path) => {
                // Try to determine resource name based on path
                if path.contains("aws") {
                    "example_vpc"
                } else if path.contains("azure") {
                    "example_res_grp"
                } else if path.contains("google") {
                    "example_vpc"
                } else {
                    "example_resource"
                }
            }
        }
    }
}

/// Configures the `init` command for the CLI application.
pub fn command() -> Command {
    Command::new("init")
        .about("Initialize a new stackql-deploy project structure")
        .arg(
            Arg::new("stack_name")
                .help("Name of the new stack project")
                .required(true)
                .index(1),
        )
        .arg(
            Arg::new("provider")
                .short('p')
                .long("provider")
                .help("Specify a provider (aws, azure, google)")
                .action(ArgAction::Set)
                .conflicts_with("template"),
        )
        .arg(
            Arg::new("template")
                .short('t')
                .long("template")
                .help("Template path or URL (e.g., 'aws/starter' or full GitHub URL)")
                .action(ArgAction::Set)
                .conflicts_with("provider"),
        )
        .arg(
            Arg::new("env")
                .short('e')
                .long("env")
                .help("Default environment for examples in generated README (default: dev)")
                .default_value("dev")
                .action(ArgAction::Set),
        )
}

/// Executes the `init` command to initialize a new project structure.
pub fn execute(matches: &ArgMatches) {
    print_unicode_box(
        "Initializing new project...",
        crate::utils::display::BorderColor::Yellow,
    );

    let stack_name = matches
        .get_one::<String>("stack_name")
        .expect("Stack name is required");

    let stack_name = stack_name.replace('_', "-").to_lowercase();

    let env = matches
        .get_one::<String>("env")
        .expect("Environment defaulted to dev")
        .to_string();

    // Check if using custom template or provider
    let template_source = if let Some(template_path) = matches.get_one::<String>("template") {
        TemplateSource::Custom(template_path.clone())
    } else {
        // Get the provider with validation
        let provider = validate_provider(matches.get_one::<String>("provider").map(|s| s.as_str()));
        TemplateSource::Embedded(provider)
    };

    // Create project structure
    match create_project_structure(&stack_name, &template_source, &env) {
        Ok(_) => {
            print_success!("Project '{}' initialized successfully.", stack_name);
        }
        Err(e) => {
            print_error!("Error initializing project: {}", e);
        }
    }
}

/// Validates the provided provider and returns the appropriate string value.
fn validate_provider(provider: Option<&str>) -> String {
    let supported: HashSet<&str> = SUPPORTED_PROVIDERS.iter().cloned().collect();

    match provider {
        Some(p) if supported.contains(p) => p.to_string(),
        Some(p) => {
            print_info!("Provider '{}' is not supported for `init`, supported providers are: {}, defaulting to `{}`",
                p, SUPPORTED_PROVIDERS.join(", "), DEFAULT_PROVIDER
            );
            DEFAULT_PROVIDER.to_string()
        }
        _none => {
            // Silently default to DEFAULT_PROVIDER
            DEFAULT_PROVIDER.to_string()
        }
    }
}

/// Fetches template content from a given URL.
fn fetch_template(url: &str) -> Result<String, String> {
    let client = Client::builder()
        .user_agent(concat!("stackql-deploy/", env!("CARGO_PKG_VERSION")))
        .build()
        .map_err(|e| format!("Failed to create HTTP client: {}", e))?;
    let response = client
        .get(url)
        .send()
        .map_err(|e| format!("Failed to fetch template: {}", e))?;

    // Check if response is successful (status code 200-299)
    if !response.status().is_success() {
        // Handle 404 and other error status codes
        if response.status() == StatusCode::NOT_FOUND {
            return Err(format!("Template not found at URL: {}", url));
        } else {
            return Err(format!(
                "Failed to fetch template: HTTP status {}",
                response.status()
            ));
        }
    }

    response
        .text()
        .map_err(|e| format!("Failed to read template content: {}", e))
}

/// Normalizes GitHub URL to raw content URL
fn normalize_github_url(url: &str) -> String {
    if url.starts_with("https://github.com") {
        // Convert github.com URL to raw.githubusercontent.com
        url.replace("https://github.com", "https://raw.githubusercontent.com")
            .replace("/tree/", "/")
    } else {
        url.to_string()
    }
}

/// Builds full URL or path for templates
fn build_template_url(template_path: &str, resource_name: &str, file_type: &str) -> String {
    // Check if template_path is an absolute URL
    if template_path.starts_with("http://") || template_path.starts_with("https://") {
        let base_url = normalize_github_url(template_path);

        match file_type {
            "resource" => format!("{}/resources/{}.iql.template", base_url, resource_name),
            "manifest" => format!("{}/stackql_manifest.yml.template", base_url),
            "readme" => format!("{}/README.md.template", base_url),
            _ => base_url,
        }
    } else {
        // It's a relative path, prepend with GitHub template base
        let base_url = format!("{}{}", GITHUB_TEMPLATE_BASE, template_path);

        match file_type {
            "resource" => format!("{}/resources/{}.iql.template", base_url, resource_name),
            "manifest" => format!("{}/stackql_manifest.yml.template", base_url),
            "readme" => format!("{}/README.md.template", base_url),
            _ => base_url,
        }
    }
}

/// Gets the template content based on the source and type
fn get_template_content(
    template_source: &TemplateSource,
    template_type: &str,
    resource_name: &str,
) -> Result<String, String> {
    match template_source {
        TemplateSource::Embedded(provider) => {
            // Use embedded templates
            match (provider.as_str(), template_type) {
                ("aws", "resource") => Ok(aws_templates::RESOURCE_TEMPLATE.to_string()),
                ("aws", "manifest") => Ok(aws_templates::MANIFEST_TEMPLATE.to_string()),
                ("aws", "readme") => Ok(aws_templates::README_TEMPLATE.to_string()),
                ("azure", "resource") => Ok(azure_templates::RESOURCE_TEMPLATE.to_string()),
                ("azure", "manifest") => Ok(azure_templates::MANIFEST_TEMPLATE.to_string()),
                ("azure", "readme") => Ok(azure_templates::README_TEMPLATE.to_string()),
                ("google", "resource") => Ok(google_templates::RESOURCE_TEMPLATE.to_string()),
                ("google", "manifest") => Ok(google_templates::MANIFEST_TEMPLATE.to_string()),
                ("google", "readme") => Ok(google_templates::README_TEMPLATE.to_string()),
                _ => Err(format!(
                    "Unsupported provider or template type: {}, {}",
                    provider, template_type
                )),
            }
        }
        TemplateSource::Custom(path) => {
            // Fetch template from URL or path
            let template_url = build_template_url(path, resource_name, template_type);

            // Fetch content from URL
            print_info!("Fetching template from: {}", template_url);
            fetch_template(&template_url)
        }
    }
}

/// Creates the project structure for a new StackQL Deploy project.
fn create_project_structure(
    stack_name: &str,
    template_source: &TemplateSource,
    env: &str,
) -> Result<(), String> {
    let cwd =
        std::env::current_dir().map_err(|e| format!("Failed to get current directory: {}", e))?;
    let base_path = cwd.join(stack_name);

    // Check if directory already exists
    if base_path.exists() {
        return Err(format!("Directory '{}' already exists", stack_name));
    }

    // Determine sample resource name based on provider
    let sample_res_name = template_source.get_sample_res_name();

    // Set up template context
    let mut context = Context::new();
    context.insert("stack_name", stack_name);
    context.insert("stack_env", env);

    // First validate that all templates can be fetched before creating any directories
    let manifest_template = get_template_content(template_source, "manifest", "")?;
    let readme_template = get_template_content(template_source, "readme", "")?;
    let resource_template = get_template_content(template_source, "resource", sample_res_name)?;

    // Now create directories
    let resource_dir = base_path.join("resources");
    fs::create_dir_all(&resource_dir)
        .map_err(|e| format!("Failed to create directories: {}", e))?;

    // Create files
    create_manifest_file(&base_path, &manifest_template, &context)?;
    create_readme_file(&base_path, &readme_template, &context)?;
    create_resource_file(&resource_dir, sample_res_name, &resource_template, &context)?;

    Ok(())
}

/// Creates a resource file in the specified directory using the provided template and context.
fn create_resource_file(
    resource_dir: &Path,
    sample_res_name: &str,
    template_str: &str,
    context: &Context,
) -> Result<(), String> {
    // Render template with Tera
    let resource_content = render_template(template_str, context)
        .map_err(|e| format!("Template rendering error: {}", e))?;

    let resource_path = resource_dir.join(format!("{}.iql", sample_res_name));
    let mut file = fs::File::create(resource_path)
        .map_err(|e| format!("Failed to create resource file: {}", e))?;

    file.write_all(resource_content.as_bytes())
        .map_err(|e| format!("Failed to write to resource file: {}", e))?;

    Ok(())
}

/// Creates a manifest file in the specified directory using the provided template and context.
fn create_manifest_file(
    base_path: &Path,
    template_str: &str,
    context: &Context,
) -> Result<(), String> {
    // Render template with Tera
    let manifest_content = render_template(template_str, context)
        .map_err(|e| format!("Template rendering error: {}", e))?;

    let manifest_path = base_path.join("stackql_manifest.yml");
    let mut file = fs::File::create(manifest_path)
        .map_err(|e| format!("Failed to create manifest file: {}", e))?;

    file.write_all(manifest_content.as_bytes())
        .map_err(|e| format!("Failed to write to manifest file: {}", e))?;

    Ok(())
}

/// Creates a README file in the specified directory using the provided template and context.
fn create_readme_file(
    base_path: &Path,
    template_str: &str,
    context: &Context,
) -> Result<(), String> {
    // Render template with Tera
    let readme_content = render_template(template_str, context)
        .map_err(|e| format!("Template rendering error: {}", e))?;

    let readme_path = base_path.join("README.md");
    let mut file = fs::File::create(readme_path)
        .map_err(|e| format!("Failed to create README file: {}", e))?;

    file.write_all(readme_content.as_bytes())
        .map_err(|e| format!("Failed to write to README file: {}", e))?;

    Ok(())
}

/// Renders a template string using Tera with the provided context.
fn render_template(template_str: &str, context: &Context) -> Result<String, String> {
    // Create a one-off Tera instance for rendering a single template
    let mut tera = Tera::default();
    tera.add_raw_template("template", template_str)
        .map_err(|e| format!("Failed to add template: {}", e))?;

    tera.render("template", context)
        .map_err(|e| format!("Failed to render template: {}", e))
}