executesoft 0.3.0

ExecuteSoft repository automation CLI
use crate::cli::SeedArgs;
use crate::util::{Result, repo_root, usage_error};
use std::env;
use std::fs;
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";

pub(crate) fn run_seed(args: SeedArgs) -> Result<()> {
    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 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!("  seed dir: {}", seed_dir.display());

    for file in files {
        apply_seed_file(&database_url, &file)?;
    }

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

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>> {
    if !dir.exists() {
        return usage_error(format!("seed 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_seed_file(database_url: &str, file: &Path) -> Result<()> {
    println!("Applying seed: {}", file.display());
    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 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::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"
        );
    }
}