eggrd 0.2.0

A drop-in Rust edge proxy that gives any app a secure front door: auth, rate limiting, and hardened response headers, with zero changes to the upstream app.
Documentation
//! Project scaffolding for `edgeguard init`.
//!
//! The first five minutes decide whether someone adopts a front door for their (often
//! generated) app. `init` removes the "read the whole README, hand-write a config and a
//! Dockerfile" step: it detects the upstream app's runtime from the files in the working
//! directory and renders a starter `edgeguard.toml` plus a `Dockerfile` that wraps the app
//! behind EdgeGuard with `--wrap`.
//!
//! This module is the pure core (detection + templating) so it's unit-testable; the CLI in
//! `main.rs` does the filesystem side (scan the directory, write the files, print next steps).

/// The annotated, secure-by-default config reference, embedded at compile time so the scaffold
/// `edgeguard.toml` is byte-identical to the documented reference and can never drift from it.
pub const EDGEGUARD_TOML: &str = include_str!("../edgeguard.toml");

/// A detected upstream-app runtime. Drives the default app port, the `--wrap` start command, and
/// the base image in the generated Dockerfile.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Runtime {
    Node,
    Python,
    Go,
    Rust,
    /// Nothing recognized — emit a generic template the user fills in.
    Unknown,
}

impl Runtime {
    /// Detect the runtime from the set of file names present in the project root. Priority order
    /// (most-common-for-a-vibe-coded-app first) resolves a repo that carries more than one marker.
    pub fn detect(entries: &[String]) -> Runtime {
        let has = |name: &str| entries.iter().any(|e| e == name);
        if has("package.json") {
            Runtime::Node
        } else if has("requirements.txt") || has("pyproject.toml") || has("Pipfile") {
            Runtime::Python
        } else if has("go.mod") {
            Runtime::Go
        } else if has("Cargo.toml") {
            Runtime::Rust
        } else {
            Runtime::Unknown
        }
    }

    pub fn label(self) -> &'static str {
        match self {
            Runtime::Node => "Node.js",
            Runtime::Python => "Python",
            Runtime::Go => "Go",
            Runtime::Rust => "Rust",
            Runtime::Unknown => "unknown",
        }
    }

    /// The port the wrapped app is told to listen on (`APP_PORT`). EdgeGuard takes the public
    /// port and proxies to this one on localhost.
    pub fn default_app_port(self) -> u16 {
        match self {
            // Conventional defaults per ecosystem; the generated config/Dockerfile make it
            // explicit so the user can change it in one place.
            Runtime::Python => 8000,
            _ => 3000,
        }
    }

    /// The default `--wrap` start command for this runtime (a placeholder the user adjusts to
    /// their actual entrypoint).
    pub fn default_start_cmd(self) -> &'static str {
        match self {
            Runtime::Node => "npm start",
            Runtime::Python => "python app.py",
            Runtime::Go => "./app",
            Runtime::Rust => "./app",
            Runtime::Unknown => "<your app start command>",
        }
    }
}

/// Render a `Dockerfile` that wraps the detected app behind EdgeGuard: it copies the static
/// `edgeguard` binary from the published image, keeps the app's own base image, and makes
/// EdgeGuard the entrypoint (binding the platform `$PORT`, running the app on `APP_PORT`).
pub fn dockerfile(runtime: Runtime) -> String {
    let app_port = runtime.default_app_port();
    let start = runtime.default_start_cmd();
    // The app's base build stage differs per runtime; everything after the EdgeGuard COPY is
    // shared. We keep this deliberately simple — a starting point the user tailors to their app.
    let (base, build_notes) = match runtime {
        Runtime::Node => (
            "node:22-slim",
            "# Install deps and copy your app source:\n\
             # COPY package*.json ./\n\
             # RUN npm ci --omit=dev\n\
             # COPY . .",
        ),
        Runtime::Python => (
            "python:3.12-slim",
            "# Install deps and copy your app source:\n\
             # COPY requirements.txt ./\n\
             # RUN pip install --no-cache-dir -r requirements.txt\n\
             # COPY . .",
        ),
        Runtime::Go => (
            "debian:bookworm-slim",
            "# Copy your prebuilt binary (or add a Go build stage):\n\
             # COPY ./app ./app",
        ),
        Runtime::Rust => (
            "debian:bookworm-slim",
            "# Copy your prebuilt binary (or add a Rust build stage):\n\
             # COPY ./app ./app",
        ),
        Runtime::Unknown => (
            "debian:bookworm-slim",
            "# Install deps and copy your app source here.",
        ),
    };

    format!(
        "# Generated by `edgeguard init` ({label} app).\n\
         # EdgeGuard is the entrypoint: it binds the platform's $PORT and runs your app on\n\
         # APP_PORT (localhost only), adding auth + rate-limit + hardened headers with no app\n\
         # code changes. Review the TODOs below and wire in your real build steps.\n\
         FROM {base}\n\
         WORKDIR /app\n\
         \n\
         # --- the EdgeGuard binary (static musl, distroless-built) ---\n\
         COPY --from=mancube/eggrd:latest /usr/local/bin/edgeguard /usr/local/bin/edgeguard\n\
         COPY edgeguard.toml /app/edgeguard.toml\n\
         \n\
         {build_notes}\n\
         \n\
         # EdgeGuard listens on $PORT (default 8080) and runs your app on APP_PORT.\n\
         ENV PORT=8080 APP_PORT={app_port}\n\
         EXPOSE 8080\n\
         ENTRYPOINT [\"/usr/local/bin/edgeguard\", \"--config\", \"/app/edgeguard.toml\", \
         \"--wrap\", \"{start}\"]\n",
        label = runtime.label(),
        base = base,
        build_notes = build_notes,
        app_port = app_port,
        start = start,
    )
}

/// The next-steps blurb printed after `init` writes its files: how to set a credential and run.
pub fn next_steps(runtime: Runtime) -> String {
    let app_port = runtime.default_app_port();
    format!(
        "Next steps:\n\
         1. Set a real credential — the shipped edgeguard.toml ships a non-working placeholder:\n\
         \x20    echo -n 'your-password' | edgeguard --hash\n\
         \x20    # paste the $argon2id$… string as the user's value in edgeguard.toml\n\
         2. Review edgeguard.toml (auth mode, rate limits, CORS if your frontend is on another origin).\n\
         3. Validate it:    edgeguard doctor --config edgeguard.toml\n\
         4. Run locally:    PORT=8080 APP_PORT={app_port} edgeguard --config edgeguard.toml --wrap \"{start}\"\n\
         5. Or build the generated Dockerfile and deploy it as your container entrypoint.\n",
        app_port = app_port,
        start = runtime.default_start_cmd(),
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    fn names(v: &[&str]) -> Vec<String> {
        v.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn detects_each_runtime() {
        assert_eq!(Runtime::detect(&names(&["package.json"])), Runtime::Node);
        assert_eq!(
            Runtime::detect(&names(&["requirements.txt"])),
            Runtime::Python
        );
        assert_eq!(
            Runtime::detect(&names(&["pyproject.toml"])),
            Runtime::Python
        );
        assert_eq!(Runtime::detect(&names(&["go.mod"])), Runtime::Go);
        assert_eq!(Runtime::detect(&names(&["Cargo.toml"])), Runtime::Rust);
        assert_eq!(Runtime::detect(&names(&["README.md"])), Runtime::Unknown);
    }

    #[test]
    fn node_wins_over_other_markers() {
        // A polyglot repo (e.g. a JS frontend + Cargo tooling) resolves to the most common app
        // runtime first.
        assert_eq!(
            Runtime::detect(&names(&["Cargo.toml", "package.json"])),
            Runtime::Node
        );
    }

    #[test]
    fn embedded_config_is_the_real_reference() {
        // Guards the include_str path: the scaffold config must be the annotated reference.
        assert!(EDGEGUARD_TOML.contains("[server]"));
        assert!(EDGEGUARD_TOML.contains("EdgeGuard configuration"));
    }

    #[test]
    fn dockerfile_wires_entrypoint_and_app_port() {
        let df = dockerfile(Runtime::Python);
        assert!(df.contains("APP_PORT=8000"), "{df}");
        assert!(df.contains("mancube/eggrd:latest"), "{df}");
        assert!(df.contains("--wrap"), "{df}");
        assert!(df.contains("python app.py"), "{df}");
    }
}