kegani-cli 0.1.4

CLI tool for Kegani framework
Documentation
//! `keg run` command — Run the application with hot reload
//!
//! Watches .rs/.yaml/.toml files and recompiles when changes are detected.
//! Shows a spinner during compilation, reports errors inline, and waits for the
//! next change after server stops.

use anyhow::{Context, Result};
use console::{style, Emoji};
use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
use std::io::{self, Write};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

/// Run with hot reload
pub fn run(port: u16, watch: bool, env: &str) -> Result<()> {
    println!();
    println!("{} {}", Emoji("", ""), style("Starting Kegani dev server").bold());
    println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());
    println!("  {} {}", style("Port:").dim(), style(port).cyan());
    println!(
        "  {} {}",
        style("Hot reload:").dim(),
        style(if watch { "enabled" } else { "disabled" }).cyan()
    );
    println!("  {} {}", style("Environment:").dim(), style(env).cyan());
    println!("{}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").dim());

    if watch {
        run_with_watch(port, env)
    } else {
        run_once(port, env)
    }
}

fn run_once(port: u16, env: &str) -> Result<()> {
    let mut cmd = Command::new("cargo");
    cmd.arg("run")
        .env("RUST_LOG", "info")
        .env("RUST_BACKTRACE", "1")
        .env("APP_ENV", env);
    // If PORT env is set, respect it
    if std::env::var("PORT").is_err() {
        cmd.env("PORT", port.to_string());
    }
    let status = cmd.status().context("Failed to run cargo run")?;

    if !status.success() {
        anyhow::bail!("Server exited with error code: {:?}", status.code());
    }
    Ok(())
}

fn run_with_watch(port: u16, env: &str) -> Result<()> {
    let project_dir =
        std::env::current_dir().context("Failed to get current directory")?;

    println!();
    println!(
        "{} {} watching for .rs/.yaml/.toml changes...",
        style("👀").cyan(),
        style("Hot reload").dim()
    );
    println!("  {} Ctrl+C to stop", style("").cyan());
    println!();

    let should_restart = Arc::new(AtomicBool::new(false));

    // Channel: notify events → watcher loop
    let (tx, rx) = std::sync::mpsc::channel();

    let mut watcher = RecommendedWatcher::new(
        move |res: notify::Result<notify::Event>| {
            if let Ok(event) = res {
                let _ = tx.send(event);
            }
        },
        Config::default().with_poll_interval(Duration::from_secs(2)),
    )
    .context("Failed to create file watcher")?;

    // Watch key directories for changes
    for dir in &["src", "internal", "manifest", "migrations"] {
        let path = project_dir.join(dir);
        if path.is_dir() {
            let _ = watcher.watch(&path, RecursiveMode::Recursive);
        }
    }

    // Debounce state: track when we last triggered a restart
    let last_trigger = Arc::new(std::sync::Mutex::new(std::time::Instant::now()));

    // Watcher thread: filter events, debounce, set restart flag
    let flag = should_restart.clone();
    let last = last_trigger.clone();
    std::thread::spawn(move || {
        for event in rx {
            let trigger = event.kind.is_modify() || event.kind.is_create();

            // Only trigger on Rust, YAML, TOML, or SQL files
            let relevant: Vec<_> = event
                .paths
                .iter()
                .filter(|p| {
                    p.extension()
                        .map(|e| {
                            let ext = e.to_string_lossy();
                            ext == "rs"
                                || ext == "yaml"
                                || ext == "yml"
                                || ext == "toml"
                                || ext == "sql"
                                || ext == "env"
                        })
                        .unwrap_or(false)
                })
                .collect();

            if relevant.is_empty() {
                continue;
            }

            // Debounce: wait at least 1.5s between triggers
            let mut last_time = last.lock().unwrap();
            if last_time.elapsed() < Duration::from_secs(1) {
                continue;
            }
            *last_time = std::time::Instant::now();
            drop(last_time);

            println!();
            println!(
                "{} {} changed",
                style("📝").yellow(),
                style("File changed:").yellow()
            );
            for p in &relevant {
                if let Some(name) = p.file_name() {
                    println!("  - {}", name.to_string_lossy());
                }
            }

            if trigger {
                flag.store(true, Ordering::Relaxed);
            }
        }
    });

    // Main loop
    loop {
        // Wait for restart signal
        while !should_restart.load(Ordering::Acquire) {
            std::thread::sleep(Duration::from_millis(300));
        }
        should_restart.store(false, Ordering::Relaxed);

        println!();
        print!("{} {} ", Emoji("", ""), style("Building...").cyan());
        io::stdout().flush().ok();

        // Run cargo build (not run) — so we can show compilation errors without starting server
        let build_result = Command::new("cargo")
            .args(["build"])
            .current_dir(&project_dir)
            .env("RUST_LOG", "info")
            .output();

        match build_result {
            Ok(output) if output.status.success() => {
                println!("{}", style("✓ done").green());
                println!();
                println!(
                    "{} {} Compiled successfully! Starting server...",
                    Emoji("🚀", ""),
                    style("Server").cyan()
                );
                println!();

                // Now run the server
                run_server(&project_dir, port, env);

                println!();
                println!(
                    "{} {} Waiting for changes...",
                    Emoji("💤", ""),
                    style("Server stopped").dim()
                );
            }
            Ok(output) => {
                println!();
                print_build_errors(&output.stderr, &project_dir);
                std::println!();
                println!(
                    "{} {} Fix the errors above and this will restart automatically.",
                    style("").yellow(),
                    style("Compile error").red().bold()
                );
            }
            Err(e) => {
                println!();
                println!("{} {} {}", style("").red(), style("Error").red(), e);
            }
        }
    }
}

/// Run the server, blocking until it exits (Ctrl+C or shutdown)
fn run_server(project_dir: &std::path::Path, port: u16, env: &str) {
    let mut child = match Command::new("cargo")
        .args(["run"])
        .current_dir(project_dir)
        .env("RUST_LOG", "info")
        .env("RUST_BACKTRACE", "1")
        .env("PORT", port.to_string())
        .env("APP_ENV", env)
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .spawn()
    {
        Ok(c) => c,
        Err(e) => {
            println!("{} {} Failed to start server: {}", style("").red(), style("Error").red(), e);
            return;
        }
    };

    // Wait for the child process to exit (Ctrl+C is forwarded by actix-web)
    let _ = child.wait();
}

/// Print a concise summary of compilation errors
fn print_build_errors(stderr: &[u8], _project_dir: &std::path::Path) {
    let stderr_str = String::from_utf8_lossy(stderr);

    // Show last ~20 lines that look like errors
    let lines: Vec<_> = stderr_str.lines().rev().take(25).collect();

    // Use 120-char max width as fallback
    let max_width = 120;

    for line in lines.iter().rev() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        let display = if trimmed.len() > max_width {
            &trimmed[..max_width.saturating_sub(3)]
        } else {
            trimmed
        };

        let is_error = trimmed.contains("error[E")
            || trimmed.starts_with("error")
            || trimmed.starts_with("Error");
        let is_warning = trimmed.contains("warning");
        let is_note = trimmed.contains("note:");

        if is_error {
            println!("  {} {}", style("").red(), style(display).red().dim());
        } else if is_warning {
            println!("  {} {}", style("").yellow(), style(display).yellow().dim());
        } else if is_note {
            println!("  {} {}", style("").dim(), style(display).dim());
        }
    }
}