tovuk 0.1.88

Deploy Rust workers, static frontends, and full-stack services to Tovuk.
use super::{
    args::CliOptions,
    config::{TovukConfig, parse_tovuk_toml, validate_config},
    errors::{Result, agent_error, print_json},
    frontend_checks::frontend_package_manager,
    project_kind::ProjectKind,
};
use serde::Serialize;
use serde_json::json;
use std::{
    collections::BTreeMap,
    fs,
    path::{Path, PathBuf},
    process::{Child, Command, Stdio},
    thread,
    time::Duration,
};

const DEFAULT_FRONTEND_PORT: u16 = 5173;
const LOCAL_HOST: &str = "127.0.0.1";

#[derive(Clone, Debug, Serialize)]
struct DevPlan {
    frontend_url: Option<String>,
    kind: &'static str,
    next_actions: Vec<String>,
    processes: Vec<DevProcess>,
    project: String,
    worker_url: Option<String>,
}

#[derive(Clone, Debug, Serialize)]
struct DevProcess {
    command: String,
    cwd: String,
    env: BTreeMap<String, String>,
    name: &'static str,
}

pub(crate) fn dev(project_dir: &Path, cli: &CliOptions) -> Result<()> {
    let config = read_dev_config(project_dir, cli)?;
    let plan = create_dev_plan(project_dir, &config);
    if cli.output.json {
        return print_json(&json!({
            "ok": true,
            "mode": "plan",
            "dev": plan,
            "agent_instruction": "Run `tovuk dev --output text` to start these local processes. JSON mode returns the plan only so child process logs do not corrupt machine-readable output."
        }));
    }
    print_dev_plan(&plan);
    run_dev_processes(&plan, cli)
}

fn read_dev_config(project_dir: &Path, cli: &CliOptions) -> Result<TovukConfig> {
    let source = fs::read_to_string(project_dir.join("tovuk.toml")).map_err(|error| {
        agent_error(
            "missing_project_contract",
            format!("Could not read tovuk.toml: {error}"),
            "Run `tovuk dev` from a Tovuk project root or pass the project path.",
            cli.output.json,
        )
    })?;
    let config = parse_tovuk_toml(&source, project_dir).and_then(|config| {
        validate_config(&config)?;
        Ok(config)
    });
    config.map_err(|message| {
        agent_error(
            "invalid_project_contract",
            format!("Invalid tovuk.toml: {message}"),
            "Fix tovuk.toml, then rerun `tovuk dev`.",
            cli.output.json,
        )
    })
}

fn create_dev_plan(project_dir: &Path, config: &TovukConfig) -> DevPlan {
    let mut processes = Vec::new();
    let mut worker_url = None;
    let mut frontend_url = None;

    if matches!(
        config.kind,
        ProjectKind::RustWorker | ProjectKind::Fullstack
    ) {
        let worker_root = config
            .backend
            .root
            .as_deref()
            .map_or_else(|| project_dir.to_path_buf(), |root| project_dir.join(root));
        let worker_port = config.backend.port.unwrap_or(config.run.port);
        let command = local_worker_command(&worker_root, config);
        worker_url = Some(format!("http://{LOCAL_HOST}:{worker_port}"));
        processes.push(DevProcess {
            command,
            cwd: display_path(&worker_root),
            env: BTreeMap::from([("PORT".to_owned(), worker_port.to_string())]),
            name: "worker",
        });
    }

    if matches!(
        config.kind,
        ProjectKind::StaticFrontend | ProjectKind::Fullstack
    ) {
        let frontend_root = config
            .frontend
            .root
            .as_deref()
            .map_or_else(|| project_dir.to_path_buf(), |root| project_dir.join(root));
        let command = local_frontend_command(&frontend_root);
        let mut env = BTreeMap::new();
        if let Some(url) = worker_url.as_deref() {
            env.insert("VITE_API_URL".to_owned(), format!("{url}/api"));
        }
        frontend_url = Some(format!("http://{LOCAL_HOST}:{DEFAULT_FRONTEND_PORT}"));
        processes.push(DevProcess {
            command,
            cwd: display_path(&frontend_root),
            env,
            name: "frontend",
        });
    }

    DevPlan {
        frontend_url,
        kind: config.kind.as_str(),
        next_actions: vec![
            "Open frontend_url in a browser for local UX testing.".to_owned(),
            "Use Ctrl-C to stop all local dev processes.".to_owned(),
            "Run `tovuk check` before deploying.".to_owned(),
        ],
        processes,
        project: display_path(project_dir),
        worker_url,
    }
}

fn local_worker_command(worker_root: &Path, config: &TovukConfig) -> String {
    if worker_root.join("Cargo.toml").exists() {
        return "cargo run --release".to_owned();
    }
    config
        .backend
        .command
        .clone()
        .or_else(|| config.run.command.clone())
        .unwrap_or_else(|| "cargo run --release".to_owned())
}

fn local_frontend_command(frontend_root: &Path) -> String {
    match frontend_package_manager(frontend_root) {
        "bun" => {
            format!("bun run dev --host {LOCAL_HOST} --port {DEFAULT_FRONTEND_PORT} --strictPort")
        }
        _ => format!(
            "npm run dev -- --host {LOCAL_HOST} --port {DEFAULT_FRONTEND_PORT} --strictPort"
        ),
    }
}

fn print_dev_plan(plan: &DevPlan) {
    println!("project {}", plan.project);
    if let Some(url) = plan.worker_url.as_deref() {
        println!("worker {url}");
    }
    if let Some(url) = plan.frontend_url.as_deref() {
        println!("frontend {url}");
    }
    for process in &plan.processes {
        let env = process
            .env
            .iter()
            .map(|(key, value)| format!("{key}={value}"))
            .collect::<Vec<_>>()
            .join(" ");
        let prefix = if env.is_empty() {
            String::new()
        } else {
            format!("{env} ")
        };
        println!(
            "{}: (cd '{}' && {}{})",
            process.name, process.cwd, prefix, process.command
        );
    }
}

fn run_dev_processes(plan: &DevPlan, cli: &CliOptions) -> Result<()> {
    let mut children = Vec::new();
    for process in &plan.processes {
        children.push(start_process(process, cli)?);
    }
    wait_for_any_process(&mut children, cli)
}

fn start_process(process: &DevProcess, cli: &CliOptions) -> Result<RunningProcess> {
    let mut command = shell_command(&process.command);
    command
        .current_dir(PathBuf::from(&process.cwd))
        .stdin(Stdio::null())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit());
    for (key, value) in &process.env {
        command.env(key, value);
    }
    let child = command.spawn().map_err(|error| {
        agent_error(
            "dev_process_failed",
            format!("Could not start {} dev process: {error}", process.name),
            "Install the required local toolchain, check the printed command, then rerun `tovuk dev`.",
            cli.output.json,
        )
    })?;
    Ok(RunningProcess {
        child,
        name: process.name,
    })
}

fn wait_for_any_process(children: &mut [RunningProcess], cli: &CliOptions) -> Result<()> {
    loop {
        for index in 0..children.len() {
            if let Some(status) = children[index].child.try_wait().map_err(|error| {
                agent_error(
                    "dev_process_failed",
                    format!(
                        "Could not inspect {} dev process: {error}",
                        children[index].name
                    ),
                    "Stop local dev processes and rerun `tovuk dev`.",
                    cli.output.json,
                )
            })? {
                stop_other_processes(children, index);
                if status.success() {
                    return Ok(());
                }
                return Err(agent_error(
                    "dev_process_exited",
                    format!("{} dev process exited with {status}.", children[index].name),
                    "Read the local process output above, fix the failing command, then rerun `tovuk dev`.",
                    cli.output.json,
                ));
            }
        }
        thread::sleep(Duration::from_millis(300));
    }
}

fn stop_other_processes(children: &mut [RunningProcess], exiting_index: usize) {
    for (index, process) in children.iter_mut().enumerate() {
        if index != exiting_index {
            let _ignore = process.child.kill();
        }
    }
}

struct RunningProcess {
    child: Child,
    name: &'static str,
}

fn shell_command(source: &str) -> Command {
    if cfg!(windows) {
        let mut command = Command::new("cmd");
        command.args(["/C", source]);
        command
    } else {
        let mut command = Command::new("sh");
        command.args(["-c", source]);
        command
    }
}

fn display_path(path: &Path) -> String {
    path.to_string_lossy().into_owned()
}

#[cfg(test)]
mod tests {
    use super::{create_dev_plan, read_dev_config};
    use crate::cli::args::CliOptions;
    use std::{env, fs, path::PathBuf, time::SystemTime};

    #[test]
    fn fullstack_plan_wires_worker_and_frontend() -> Result<(), Box<dyn std::error::Error>> {
        let project_dir = temp_project_dir("fullstack-plan")?;
        fs::create_dir_all(project_dir.join("api"))?;
        fs::create_dir_all(project_dir.join("web"))?;
        fs::write(
            project_dir.join("api/Cargo.toml"),
            "[package]\nname = \"api\"\n",
        )?;
        fs::write(
            project_dir.join("web/package.json"),
            "{\"scripts\":{\"dev\":\"vite\"}}",
        )?;
        fs::write(project_dir.join("web/bun.lock"), "")?;
        fs::write(
            project_dir.join("tovuk.toml"),
            r#"
name = "demo"
kind = "fullstack"

[capabilities]
static_frontend = true
worker = true
sqlite = false
object_storage = false
kv = false
state = false
queue = false
cron = false
service_bindings = false
secrets = false
custom_domains = false
logs = true
builds = true
usage_caps = true
billing = true
support = true
abuse = true

[worker]
root = "api"
command = "./target/release/api"
port = 3000
health = "/api/healthz"

[frontend]
root = "web"
output = "dist"
"#,
        )?;

        let cli = CliOptions::default();
        let config = read_dev_config(&project_dir, &cli)?;
        let plan = create_dev_plan(&project_dir, &config);

        if plan.processes.len() != 2 {
            return Err(format!("unexpected process count: {}", plan.processes.len()).into());
        }
        if plan.worker_url.as_deref() != Some("http://127.0.0.1:3000") {
            return Err(format!("unexpected worker url: {:?}", plan.worker_url).into());
        }
        if plan.frontend_url.as_deref() != Some("http://127.0.0.1:5173") {
            return Err(format!("unexpected frontend url: {:?}", plan.frontend_url).into());
        }
        if plan.processes[0].command != "cargo run --release" {
            return Err(format!("unexpected worker command: {}", plan.processes[0].command).into());
        }
        if plan.processes[0].env.get("PORT").map(String::as_str) != Some("3000") {
            return Err(format!("unexpected worker env: {:?}", plan.processes[0].env).into());
        }
        if plan.processes[1]
            .env
            .get("VITE_API_URL")
            .map(String::as_str)
            != Some("http://127.0.0.1:3000/api")
        {
            return Err(format!("unexpected frontend env: {:?}", plan.processes[1].env).into());
        }
        if plan.processes[1].command != "bun run dev --host 127.0.0.1 --port 5173 --strictPort" {
            return Err(
                format!("unexpected frontend command: {}", plan.processes[1].command).into(),
            );
        }

        let _ignore = fs::remove_dir_all(project_dir);
        Ok(())
    }

    fn temp_project_dir(name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
        let nanos = SystemTime::now()
            .duration_since(SystemTime::UNIX_EPOCH)?
            .as_nanos();
        let path = env::temp_dir().join(format!("tovuk-{name}-{nanos}"));
        let _ignore = fs::remove_dir_all(&path);
        fs::create_dir_all(&path)?;
        Ok(path)
    }
}