executesoft 0.3.1

ExecuteSoft repository automation CLI
use crate::cli::SeedArgs;
use crate::util::{Result, repo_root, usage_error};
use std::env;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

const DEFAULT_AUTH_DATABASE_URL: &str =
    "postgres://executesoft:executesoft@localhost:5432/core_auth?sslmode=disable";
const DEFAULT_LOCAL_POSTGRES_CONTAINER: &str = "executesoft-local-dev-postgres-1";

enum SeedRunner {
    LocalPsql,
    DockerPsql {
        container: String,
        user: String,
        database: String,
    },
}

pub(crate) fn run_seed(args: SeedArgs) -> Result<()> {
    let migration_dir = migration_dir(&args)?;
    let seed_dir = seed_dir(&args)?;
    let database_url = args
        .database_url
        .or_else(|| env::var("DATABASE_URL").ok())
        .unwrap_or_else(|| DEFAULT_AUTH_DATABASE_URL.to_string());

    let migration_files = sql_files(&migration_dir)?;
    let files = seed_files(&seed_dir)?;
    if files.is_empty() {
        return usage_error(format!("no seed SQL files found in {}", seed_dir.display()));
    }

    println!("Applying local seeds:");
    println!("  database: {}", redact_database_url(&database_url));
    println!("  migration dir: {}", migration_dir.display());
    println!("  seed dir: {}", seed_dir.display());

    let runner = seed_runner(&database_url);
    for file in migration_files {
        apply_sql_file(&runner, &database_url, "migration", &file)?;
    }
    for file in files {
        apply_sql_file(&runner, &database_url, "seed", &file)?;
    }

    println!("Local seeds applied.");
    Ok(())
}

fn migration_dir(args: &SeedArgs) -> Result<PathBuf> {
    match args.service.as_str() {
        "auth" => Ok(repo_root().join("services/core/auth/migrations")),
        service => usage_error(format!(
            "unsupported seed service `{service}`; pass --seed-dir for a custom seed directory"
        )),
    }
}

fn seed_runner(database_url: &str) -> SeedRunner {
    if Command::new("psql")
        .arg("--version")
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok()
    {
        return SeedRunner::LocalPsql;
    }

    let target = parse_database_target(database_url);
    let container = env::var("SEED_DB_CONTAINER")
        .ok()
        .filter(|value| !value.trim().is_empty())
        .unwrap_or_else(|| DEFAULT_LOCAL_POSTGRES_CONTAINER.to_string());
    println!("  psql: local binary not found, using docker container `{container}`");
    SeedRunner::DockerPsql {
        container,
        user: target.user,
        database: target.database,
    }
}

fn seed_dir(args: &SeedArgs) -> Result<PathBuf> {
    if let Some(seed_dir) = &args.seed_dir {
        return Ok(path_from_repo(seed_dir));
    }

    match args.service.as_str() {
        "auth" => Ok(repo_root().join("services/core/auth/migrations/seed")),
        service => usage_error(format!(
            "unsupported seed service `{service}`; pass --seed-dir for a custom seed directory"
        )),
    }
}

fn path_from_repo(value: &str) -> PathBuf {
    let path = PathBuf::from(value);
    if path.is_absolute() {
        path
    } else {
        repo_root().join(path)
    }
}

fn seed_files(dir: &Path) -> Result<Vec<PathBuf>> {
    sql_files(dir)
}

fn sql_files(dir: &Path) -> Result<Vec<PathBuf>> {
    if !dir.exists() {
        return usage_error(format!("SQL directory does not exist: {}", dir.display()));
    }

    let mut files = Vec::new();
    for entry in fs::read_dir(dir)? {
        let path = entry?.path();
        if path.is_file() && path.extension().is_some_and(|extension| extension == "sql") {
            files.push(path);
        }
    }
    files.sort();
    Ok(files)
}

fn apply_sql_file(runner: &SeedRunner, database_url: &str, label: &str, file: &Path) -> Result<()> {
    println!("Applying {label}: {}", file.display());
    match runner {
        SeedRunner::LocalPsql => apply_sql_file_with_local_psql(database_url, file),
        SeedRunner::DockerPsql {
            container,
            user,
            database,
        } => apply_sql_file_with_docker_psql(container, user, database, file),
    }
}

fn apply_sql_file_with_local_psql(database_url: &str, file: &Path) -> Result<()> {
    let status = Command::new("psql")
        .args(["-v", "ON_ERROR_STOP=1", database_url, "-f"])
        .arg(file)
        .stdin(Stdio::null())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .map_err(|error| {
            format!(
                "failed to start psql: {error}. Install psql or pass a working local DATABASE_URL."
            )
        })?;

    if status.success() {
        Ok(())
    } else {
        Err(format!("psql failed for {} with {status}", file.display()).into())
    }
}

fn apply_sql_file_with_docker_psql(
    container: &str,
    user: &str,
    database: &str,
    file: &Path,
) -> Result<()> {
    let sql = fs::read_to_string(file)?;
    let mut child = Command::new("docker")
        .args([
            "exec",
            "-i",
            container,
            "psql",
            "-v",
            "ON_ERROR_STOP=1",
            "-U",
            user,
            "-d",
            database,
        ])
        .stdin(Stdio::piped())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .spawn()
        .map_err(|error| {
            format!(
                "failed to start docker psql in container `{container}`: {error}. Start local dependencies with `exe dev up`."
            )
        })?;

    child
        .stdin
        .as_mut()
        .ok_or("docker psql stdin was not available")?
        .write_all(sql.as_bytes())?;
    let status = child.wait()?;
    if status.success() {
        Ok(())
    } else {
        Err(format!("docker psql failed for {} with {status}", file.display()).into())
    }
}

struct DatabaseTarget {
    user: String,
    database: String,
}

fn parse_database_target(database_url: &str) -> DatabaseTarget {
    let default = DatabaseTarget {
        user: "executesoft".to_string(),
        database: "core_auth".to_string(),
    };
    let Some((_scheme, rest)) = database_url.split_once("://") else {
        return default;
    };
    let Some((auth_and_host, path_and_query)) = rest.rsplit_once('/') else {
        return default;
    };
    let database = path_and_query
        .split_once('?')
        .map(|(database, _query)| database)
        .unwrap_or(path_and_query)
        .trim();
    let user = auth_and_host
        .split_once('@')
        .map(|(credentials, _host)| credentials)
        .and_then(|credentials| credentials.split_once(':').map(|(user, _password)| user))
        .unwrap_or("executesoft")
        .trim();

    DatabaseTarget {
        user: if user.is_empty() {
            default.user
        } else {
            user.to_string()
        },
        database: if database.is_empty() {
            default.database
        } else {
            database.to_string()
        },
    }
}

fn redact_database_url(database_url: &str) -> String {
    let Some((scheme, rest)) = database_url.split_once("://") else {
        return database_url.to_string();
    };
    let Some((credentials, host_and_path)) = rest.split_once('@') else {
        return database_url.to_string();
    };
    let Some((user, _password)) = credentials.split_once(':') else {
        return database_url.to_string();
    };
    format!("{scheme}://{user}:<redacted>@{host_and_path}")
}

#[cfg(test)]
mod tests {
    use super::{parse_database_target, redact_database_url};

    #[test]
    fn redacts_database_password() {
        assert_eq!(
            redact_database_url("postgres://executesoft:secret@localhost:5432/core_auth"),
            "postgres://executesoft:<redacted>@localhost:5432/core_auth"
        );
    }

    #[test]
    fn parses_database_target_from_url() {
        let target = parse_database_target(
            "postgres://executesoft:secret@localhost:5432/core_auth?sslmode=disable",
        );

        assert_eq!(target.user, "executesoft");
        assert_eq!(target.database, "core_auth");
    }
}