use crate::cli::{DevCommand, KeyValueArgs, WatchArgs};
use crate::service_local::{
configure_current_or_compose_service_dev_environment, 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(),
}
}
fn run_dev_command(command: DevCommand) -> Result<()> {
match command {
DevCommand::Up(args) => {
apply_key_value_env(&args);
configure_current_or_compose_service_dev_environment();
let mut args = vec!["up".to_string(), "-d".to_string()];
args.extend(dev_services());
docker_compose_owned(args)
}
DevCommand::All(args) => {
apply_key_value_env(&args);
configure_current_or_compose_service_dev_environment();
docker_compose(&["up", "--build"])
}
DevCommand::Build(args) => {
apply_key_value_env(&args);
configure_current_or_compose_service_dev_environment();
docker_compose(&["build"])
}
DevCommand::Down(args) => {
apply_key_value_env(&args);
configure_current_or_compose_service_dev_environment();
docker_compose(&["down"])
}
DevCommand::Logs(args) => {
apply_key_value_env(&args);
configure_current_or_compose_service_dev_environment();
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);
configure_current_or_compose_service_dev_environment();
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").map_err(|_| {
"DEV_COMPOSE_FILE is required for compose-based dev commands. Use service-local `exe dev` inside a service checkout, or pass DEV_COMPOSE_FILE=path/to/compose.yml."
})?;
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 dev_services() -> Vec<String> {
env::var("DEV_SERVICES")
.unwrap_or_else(|_| "postgres redis nats".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;
}
}
}
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(())
}