bular 0.0.2

CLI for managing Bular deployments
use std::{
    collections::HashMap,
    env,
    fs::{self, DirEntry},
    io,
    path::{absolute, Path, PathBuf},
};

use crate::{
    api::{Api, Deployment, DeploymentFramework, DeploymentGit, DeploymentOverride},
    config::{Config, Destination},
    manifest::Entrypoint,
    rust::build,
};
use cargo_metadata::{CargoOpt, MetadataCommand};
use rand::{distributions::Alphanumeric, thread_rng, Rng};
use reqwest::{
    multipart::{Form, Part},
    Client,
};
use serde::Deserialize;

use crate::bular_json::BularJson;

#[derive(clap::Args)]
#[command(about = "Deploy your application!")]
pub struct Command {
    // TODO: `--message bruh`
}

impl Command {
    pub async fn run(&self, cwd: PathBuf, server: String) {
        let bular_json = BularJson::load();

        let Ok(config) = std::fs::read_to_string(cwd.join("bular.yaml")) else {
            eprintln!("No `bular.yaml` file found in the current directory."); // TODO: Technically not correct but ehh
            std::process::exit(1);
        };
        let config: Config = serde_yaml::from_str(&config).unwrap();

        let mut deployment = Deployment {
            version: 1,
            framework: Some(DeploymentFramework {
                name: "bular-cli".to_string(),
                version: env!("CARGO_PKG_VERSION").to_string(),
                href: Some("https://github.com/specta-rs/bular.git".into()),
            }),
            git: git_info(),
            routes: None,
            assets: None,
            overrides: None,
        };
        let mut form = Form::new();

        if let Some(_static) = config._static {
            if let Some(build_cmd) = _static.build {
                println!("Running build command: {build_cmd:?}");
                command_from_string(&build_cmd)
                    .current_dir(&cwd)
                    .status()
                    .expect("Failed to build static files");
            }

            if let Some(static_dir) = _static.dist {
                let static_dir = absolute(cwd.join(static_dir)).unwrap();
                if !static_dir.exists() {
                    eprintln!("Static directory does not exist: {static_dir:?}");
                    std::process::exit(1);
                }

                let mut asset_manifest = HashMap::new(); // path -> hash
                let mut overrides = HashMap::new(); // path -> override
                let mut assets = HashMap::new(); // hash -> content

                visit_dirs(&static_dir, &mut |entry| {
                    if !entry.file_type().unwrap().is_file() {
                        return;
                    }

                    let key = entry
                        .path()
                        .strip_prefix(&static_dir)
                        .unwrap()
                        .to_str()
                        .unwrap()
                        .to_string();

                    // TODO: File streaming instead?
                    let content = std::fs::read(entry.path()).unwrap();
                    let hash = sha256::digest(content.as_slice()).to_string();

                    asset_manifest.insert(key.clone(), hash.clone());
                    overrides.insert(
                        key.clone(),
                        DeploymentOverride {
                            content_type: mime_infer::from_path(entry.path())
                                .first()
                                .map(|m| m.to_string()),
                            ..Default::default()
                        },
                    );

                    let a = assets.get(&hash);
                    if a.is_some() && a != Some(&content) {
                        eprintln!(
                            "Hash collision detected at ${key} with hash ${hash}: ${a:?} !== ${content:?}"
                        );
                        std::process::exit(1);
                    }
                    assets.insert(hash, content);
                })
                .unwrap();

                deployment.assets = Some(asset_manifest);
                deployment.overrides = Some(overrides);

                for (hash, content) in assets {
                    form = form.part(hash, Part::bytes(content));
                }
            }
        }

        let mut clients = None;

        for path in config.paths {
            match path.destination {
                Some(Destination::Rewrite { to }) => todo!(),
                Some(Destination::Redirect { redirect }) => todo!(),
                Some(Destination::Rust { _crate, bin }) => {
                    let (lambda, iam) = loop {
                        let Some(value) = &clients else {
                            let config = aws_config::from_env().region("us-east-1").load().await;
                            let lambda = aws_sdk_lambda::Client::new(&config);
                            let iam = aws_sdk_iam::Client::new(&config);

                            clients = Some((lambda, iam));
                            continue;
                        };
                        break value;
                    };

                    // TODO: Allow generic Lambda build command and then we can just yeet up the output without caring

                    let metadata = MetadataCommand::new()
                        // .manifest_path("./Cargo.toml")
                        .current_dir(&cwd)
                        .features(CargoOpt::AllFeatures)
                        .exec()
                        .unwrap();

                    let target_dir = metadata.target_directory.into_std_path_buf(); // TODO: make this work in monorepo's!!!
                    let bin = _crate.clone(); // TODO: Make this work properly

                    let entrypoint = Entrypoint {
                        _crate: _crate.clone(),
                        environment: Default::default(), // TODO:
                        resources: Default::default(),   // TODO
                        lambda: None,                    // TODO
                    };

                    let binary_path = crate::rust::build(&cwd, &target_dir, &bin, &entrypoint);

                    let name = format!(
                        "{}-{}",
                        bin,
                        // _crate, // TODO: Drop this if it's the same as the `bin`
                        thread_rng()
                            .sample_iter(&Alphanumeric)
                            .take(4)
                            .map(char::from)
                            .collect::<String>()
                    );

                    let result = crate::lambda::deploy(
                        &name,
                        &entrypoint,
                        binary_path,
                        &iam,
                        &lambda,
                        Default::default(),
                    )
                    .await;

                    println!("{:?}", result); // TODO: Add this into the manifest
                }
                Some(Destination::Final { r#final: bool }) => todo!(),
                None => todo!(),
            }
        }

        if let Some(bular_app_id) = config.bular_id {
            let client = reqwest::Client::builder()
                .user_agent(concat!(
                    env!("CARGO_PKG_NAME"),
                    "/",
                    env!("CARGO_PKG_VERSION"),
                ))
                .build()
                .unwrap();

            let Some(bular_bearer_token) = get_access_token(&client, &bular_json).await else {
                eprintln!("No Bular configuration found. Please run `bular login` first.");
                std::process::exit(1);
            };

            let api = Api::new(client, server, bular_bearer_token).unwrap();

            // TODO: Use deployment planning API

            let result = api.deployment(bular_app_id, deployment, form).await;
            println!("{result:?}");
        }
    }
}

fn command_from_string(cmd: &str) -> std::process::Command {
    if cfg!(target_os = "windows") {
        let mut c = std::process::Command::new("cmd");
        c.args(["/C", cmd]);
        c
    } else {
        let mut c = std::process::Command::new("sh");
        c.args(["-c", cmd]);
        c
    }
}

fn git_info() -> Option<DeploymentGit> {
    let commit = std::process::Command::new("git")
        .args(["rev-parse", "HEAD"])
        .output()
        .ok()?
        .stdout;

    let message = std::process::Command::new("git")
        .args(["log", "-1", "--pretty=%B"])
        .output()
        .ok()
        .and_then(|v| {
            String::from_utf8(v.stdout)
                .ok()
                .map(|v| v.trim().to_string())
        });

    let branch = std::process::Command::new("git")
        .args(["rev-parse", "--abbrev-ref", "HEAD"])
        .output()
        .ok()
        .and_then(|v| {
            String::from_utf8(v.stdout)
                .ok()
                .map(|v| v.trim().to_string())
        });

    let dirty = std::process::Command::new("git")
        .args(["diff", "--quiet"])
        .status()
        .ok()
        .map(|v| !v.success())
        .unwrap_or(false);

    Some(DeploymentGit {
        commit: String::from_utf8(commit).unwrap().trim().to_string(),
        message,
        branch,
        dirty: Some(dirty),
    })
}

fn visit_dirs(dir: &Path, cb: &mut dyn FnMut(&DirEntry)) -> io::Result<()> {
    if dir.is_dir() {
        for entry in fs::read_dir(dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.is_dir() {
                visit_dirs(&path, cb)?;
            } else {
                cb(&entry);
            }
        }
    }
    Ok(())
}

#[derive(Deserialize)]
pub struct GitHubActionsOIDCResponse {
    value: String,
}

pub async fn get_access_token(client: &Client, bular_json: &Option<BularJson>) -> Option<String> {
    if let Some(token) = bular_json.as_ref().and_then(|c| c.token.as_ref()) {
        return Some(token.to_string());
    }

    let actions_id_token_request_token = env::var("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
    let actions_id_token_request_url = env::var("ACTIONS_ID_TOKEN_REQUEST_URL");
    if let (Ok(token), Ok(url)) = (actions_id_token_request_token, actions_id_token_request_url) {
        println!("Authenticating via GitHub Actions...");

        let result: GitHubActionsOIDCResponse = client
            .get(format!("{url}&audience=https://bular.cloud"))
            .bearer_auth(token)
            .send()
            .await
            .unwrap()
            .json()
            .await
            .unwrap();
        return Some(result.value);
    }

    None
}