use crate::cli::{DevCommand, KeyValueArgs, WatchArgs};
use crate::service_local::dev_current_service;
use crate::util::{Result, repo_root, run_cmd, usage_error};
use std::collections::HashSet;
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, UNIX_EPOCH};
pub(crate) fn run_dev(command: Option<DevCommand>) -> Result<()> {
match command {
Some(command) => run_dev_command(command),
None => dev_current_service(),
}
}
pub(crate) fn run_dev_command(command: DevCommand) -> Result<()> {
match command {
DevCommand::Up(args) => {
apply_key_value_env(&args);
let mut args = vec!["up".to_string(), "-d".to_string()];
args.extend(dev_services());
docker_compose_owned(args)?;
ensure_shared_postgres_databases()
}
DevCommand::All(args) => {
apply_key_value_env(&args);
docker_compose(&["up", "--build"])
}
DevCommand::Build(args) => {
apply_key_value_env(&args);
docker_compose(&["build"])
}
DevCommand::Down(args) => {
apply_key_value_env(&args);
docker_compose(&["down"])
}
DevCommand::Logs(args) => {
apply_key_value_env(&args);
let mut args = vec!["logs".to_string(), "-f".to_string()];
args.extend(dev_services());
docker_compose_owned(args)
}
DevCommand::Reset(args) => {
apply_key_value_env(&args);
docker_compose(&["down", "-v"])
}
DevCommand::Watch(args) => run_dev_watch(args),
}
}
fn apply_key_value_env(args: &KeyValueArgs) {
for arg in &args.vars {
if let Some((key, value)) = arg.split_once('=') {
unsafe { env::set_var(key, value) };
}
}
}
fn docker_compose(args: &[&str]) -> Result<()> {
docker_compose_owned(args.iter().map(|arg| arg.to_string()).collect())
}
fn docker_compose_owned(args: Vec<String>) -> Result<()> {
let file =
env::var("DEV_COMPOSE_FILE").unwrap_or_else(|_| "tools/local-dev/compose.yml".into());
let file = resolve_compose_file(&file)?;
if !file.exists() {
return usage_error(format!(
"DEV_COMPOSE_FILE does not exist: {}",
file.display()
));
}
let mut cmd_args = vec![
"compose".to_string(),
"-f".to_string(),
file.display().to_string(),
];
cmd_args.extend(args);
run_cmd(&repo_root(), "docker", &cmd_args)
}
fn ensure_shared_postgres_databases() -> Result<()> {
if env::var("EXE_DEV_SKIP_DATABASE_BOOTSTRAP")
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"))
.unwrap_or(false)
{
return Ok(());
}
let databases = shared_postgres_databases()?.join(" ");
if databases.is_empty() {
return Ok(());
}
let script = format!(
r#"set -eu
for db in {databases}; do
if ! psql -U executesoft -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '$db'" | grep -q 1; then
createdb -U executesoft "$db"
fi
done"#,
);
run_cmd(
&repo_root(),
"docker",
&[
"compose".into(),
"-f".into(),
"tools/local-dev/compose.yml".into(),
"exec".into(),
"-T".into(),
"postgres".into(),
"sh".into(),
"-lc".into(),
script,
],
)
}
pub(crate) fn shared_postgres_databases() -> Result<Vec<String>> {
let mut databases = Vec::new();
if let Ok(extra) = env::var("EXE_DEV_DATABASES") {
databases.extend(split_database_names(&extra));
}
collect_service_databases(&repo_root().join("services"), &mut databases)?;
databases.sort();
databases.dedup();
Ok(databases)
}
pub(crate) fn collect_service_databases(root: &Path, databases: &mut Vec<String>) -> Result<()> {
if !root.exists() {
return Ok(());
}
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_service_databases(&path, databases)?;
continue;
}
if path.file_name().and_then(OsStr::to_str) == Some("app.env.example") {
collect_databases_from_env_file(&path, databases)?;
} else if path.file_name().and_then(OsStr::to_str) == Some("Makefile") {
collect_databases_from_makefile(&path, databases)?;
}
}
Ok(())
}
fn collect_databases_from_env_file(path: &Path, databases: &mut Vec<String>) -> Result<()> {
let text = fs::read_to_string(path)?;
for line in text.lines() {
let Some((key, value)) = line.trim().split_once('=') else {
continue;
};
if matches!(key.trim(), "DATABASE_URL" | "TENANT_CONTEXT_DATABASE_URL")
&& let Some(database) = postgres_database_name(value.trim())
{
databases.push(database);
}
}
Ok(())
}
fn collect_databases_from_makefile(path: &Path, databases: &mut Vec<String>) -> Result<()> {
let text = fs::read_to_string(path)?;
for line in text.lines() {
let Some((key, value)) = line.trim().split_once("?=") else {
continue;
};
if key.trim() == "MIGRATION_DB_NAME" {
databases.extend(split_database_names(value));
}
}
Ok(())
}
pub(crate) fn postgres_database_name(url: &str) -> Option<String> {
let without_query = url.split_once('?').map(|(value, _)| value).unwrap_or(url);
let database = without_query.rsplit('/').next()?.trim();
if database.is_empty() || database.contains('$') || database.contains("__") {
return None;
}
Some(database.to_string())
}
fn split_database_names(value: &str) -> Vec<String> {
value
.split([',', ' ', '\t'])
.map(|item| item.trim().trim_matches(['"', '\'']))
.filter(|item| !item.is_empty() && !item.contains('$') && !item.contains("__"))
.map(String::from)
.collect()
}
fn dev_services() -> Vec<String> {
env::var("DEV_SERVICES")
.unwrap_or_else(|_| "postgres redis nats mongodb".into())
.split([',', ' '])
.map(str::trim)
.filter(|item| !item.is_empty())
.map(String::from)
.collect()
}
fn resolve_compose_file(file: &str) -> Result<std::path::PathBuf> {
let raw = Path::new(file);
if raw.is_absolute() {
return Ok(raw.to_path_buf());
}
let cwd_path = env::current_dir()?.join(raw);
if cwd_path.exists() {
return Ok(cwd_path);
}
Ok(repo_root().join(raw))
}
pub(crate) fn run_dev_watch(args: WatchArgs) -> Result<()> {
let mut ignored: HashSet<String> = [
"response-mappers.json",
"routes.yaml",
"public-routes.json",
"route-permissions.json",
"openapi.yaml",
]
.into_iter()
.map(String::from)
.collect();
for item in args.ignore {
ignored.insert(item);
}
if args.command.is_empty() {
return usage_error("dev command is required".into());
}
load_env_file(Path::new(&args.env_file));
let mut previous = snapshot(Path::new(&args.watch_root), &ignored)?;
let mut child = start_child(&args.command)?;
let poll = Duration::from_millis((args.poll * 1000.0) as u64);
loop {
thread::sleep(poll);
let current = snapshot(Path::new(&args.watch_root), &ignored)?;
if current != previous {
println!("[dev] change detected, restarting");
stop_child(&mut child);
child = start_child(&args.command)?;
previous = current;
}
}
}
pub(crate) fn load_env_file(path: &Path) {
let Ok(text) = fs::read_to_string(path) else {
return;
};
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
if env::var(key).is_err() {
unsafe { env::set_var(key, value.trim().trim_matches(['"', '\''])) };
}
}
}
}
fn start_child(command: &[String]) -> Result<Child> {
println!("[dev] starting: {}", command.join(" "));
Ok(Command::new(&command[0])
.args(&command[1..])
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()?)
}
fn stop_child(child: &mut Child) {
let _ = child.kill();
let _ = child.wait();
}
fn snapshot(root: &Path, ignored: &HashSet<String>) -> Result<String> {
let mut rows = Vec::new();
snapshot_walk(root, ignored, &mut rows)?;
rows.sort();
Ok(rows.join("\n"))
}
fn snapshot_walk(root: &Path, ignored: &HashSet<String>, rows: &mut Vec<String>) -> Result<()> {
if !root.exists() {
return Ok(());
}
let pruned: HashSet<&str> = [
".git",
".pytest_cache",
"__pycache__",
"build",
"coverage",
"dist",
"node_modules",
"target",
]
.into_iter()
.collect();
let allowed: HashSet<&str> = [
".go", ".mod", ".sum", ".ts", ".js", ".json", ".py", ".toml", ".rs", ".yaml", ".yml",
".proto",
]
.into_iter()
.collect();
for entry in fs::read_dir(root)? {
let entry = entry?;
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if path.is_dir() {
if !pruned.contains(name.as_str()) {
snapshot_walk(&path, ignored, rows)?;
}
} else if !ignored.contains(&name)
&& path
.extension()
.and_then(OsStr::to_str)
.is_some_and(|ext| allowed.contains(format!(".{ext}").as_str()))
{
let modified = entry
.metadata()?
.modified()?
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
rows.push(format!("{modified} {}", path.display()));
}
}
Ok(())
}