use crate::cli::{DevCommand, KeyValueArgs, WatchArgs};
use crate::service_local::dev_current_service;
use crate::util::{Result, repo_root, run_cmd, run_make, 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);
run_make(&repo_root().join("services/core/gateway"), "generate", &[])?;
docker_compose(&["up", "-d", "postgres", "redis", "nats"])
}
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);
docker_compose(&["logs", "-f", "postgres", "redis", "nats"])
}
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<()> {
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."
})?;
if !repo_root().join(&file).exists() && !Path::new(&file).exists() {
return usage_error(format!("DEV_COMPOSE_FILE does not exist: {file}"));
}
let mut cmd_args = vec!["compose".to_string(), "-f".to_string(), file];
cmd_args.extend(args.iter().map(|arg| arg.to_string()));
run_cmd(&repo_root(), "docker", &cmd_args)
}
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('=') {
unsafe { env::set_var(key.trim(), 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(())
}