meld-server 0.1.0

Single-port REST + gRPC server framework with FastAPI-like DX for Rust.
use std::{
    env, fs,
    path::{Component, Path, PathBuf},
};

fn print_usage(binary_name: &str) {
    eprintln!(
        "Usage: {binary_name} [--out <path>]\n\
         Note: --out must be a repo-relative path under docs/generated/"
    );
}

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("../..")
        .canonicalize()
        .expect("workspace root should be resolvable")
}

fn docs_generated_dir(workspace_root: &Path) -> PathBuf {
    workspace_root.join("docs/generated")
}

fn resolve_output_path(raw: &str, workspace_root: &Path) -> Result<PathBuf, String> {
    let candidate = Path::new(raw);
    if candidate.as_os_str().is_empty() {
        return Err("--out path cannot be empty".to_string());
    }
    if candidate.is_absolute() {
        return Err("--out must be a repo-relative path".to_string());
    }
    if candidate
        .components()
        .any(|component| matches!(component, Component::ParentDir))
    {
        return Err("--out cannot contain parent-directory traversal ('..')".to_string());
    }

    let resolved = workspace_root.join(candidate);
    let allowed_root = docs_generated_dir(workspace_root);
    if !resolved.starts_with(&allowed_root) {
        return Err("--out must stay under docs/generated/".to_string());
    }

    Ok(resolved)
}

fn parse_out_path(workspace_root: &Path) -> Result<PathBuf, String> {
    let mut args = env::args().skip(1);
    let mut out = resolve_output_path("docs/generated/rest-openapi.json", workspace_root)?;

    while let Some(arg) = args.next() {
        match arg.as_str() {
            "--out" => {
                let value = args
                    .next()
                    .ok_or_else(|| "--out requires a path value".to_string())?;
                out = resolve_output_path(&value, workspace_root)?;
            }
            "--help" | "-h" => {
                let binary = env::args()
                    .next()
                    .unwrap_or_else(|| "openapi_docgen".to_string());
                print_usage(&binary);
                std::process::exit(0);
            }
            unknown => {
                return Err(format!("unknown argument: {unknown}"));
            }
        }
    }

    Ok(out)
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let workspace_root = workspace_root();
    let out_path = match parse_out_path(&workspace_root) {
        Ok(path) => path,
        Err(message) => {
            eprintln!("error: {message}");
            let binary = env::args()
                .next()
                .unwrap_or_else(|| "openapi_docgen".to_string());
            print_usage(&binary);
            std::process::exit(2);
        }
    };

    if let Some(parent) = out_path.parent() {
        fs::create_dir_all(parent)?;
    }

    let document = meld_server::rest_openapi_json_pretty();
    fs::write(&out_path, document)?;
    let display_path = out_path
        .strip_prefix(&workspace_root)
        .unwrap_or(out_path.as_path());
    println!("{}", display_path.display());
    Ok(())
}