rustio-admin 0.19.0

Django Admin, but for Rust. A small, focused admin framework.
Documentation
//! Auto-generated TypeScript SDK skeleton for the registered
//! models. Exposed at `GET /admin/apis/sdk.ts` (mounted in
//! `routes.rs`, Staff-gated).
//!
//! Scope of v1: per-model `export interface` declarations only.
//! No fetch client, no method helpers — operators wrap their
//! own networking. The output is one `.ts` file with:
//!
//! ```text
//! // Header comment naming the source admin + a generated-at hint.
//! export interface <Singular> { … }
//! export interface <Singular> { … }
//! ```
//!
//! Field-type mapping mirrors `json_api::typed_cell`:
//!
//! - `Bool` → `boolean`
//! - `I32` / `I64` / `OptionalI64` → `number`
//! - `String` / `OptionalString` / `DateTime` / `OptionalDateTime`
//!   / `FilePath` / `OptionalFilePath` → `string`
//! - Optional variants get `| null`
//!
//! Snake_case field names are preserved verbatim — TypeScript
//! accepts them as identifiers, and matching the wire shape keeps
//! the SDK trivially usable against the JSON envelopes
//! `json_api.rs` ships.
//!
//! Out of scope for v1: a fetch client per endpoint, request /
//! response envelope types (the OpenAPI 3.0 surface already
//! documents those — operators feed it into a richer generator
//! if they need that), bulk-action overloads.

use super::types::{Admin, AdminEntry, AdminField, FieldType};

// public:
/// Build the TypeScript SDK source string for `admin`. Pure
/// functional walk over compile-time field metadata; no DB.
pub(crate) fn build_typescript(admin: &Admin) -> String {
    let app_name = &admin.branding().app_name;
    let mut out = String::with_capacity(1024);
    out.push_str(&format!(
        "// Auto-generated by rustio-admin for {app_name}.\n\
         // Edit at your peril — re-run `curl /admin/apis/sdk.ts` to refresh.\n\
         //\n\
         // One `export interface` per registered project model.\n\
         // Wrap your own fetch around the JSON envelopes documented at\n\
         // /admin/apis/openapi.json.\n\n"
    ));
    let mut emitted = 0usize;
    for entry in admin.entries() {
        // Core entries (synthetic User) aren't part of the
        // project-data SDK. The openapi.rs spec skips them for
        // the same reason — keep both surfaces in lock-step.
        if entry.core {
            continue;
        }
        emit_interface(&mut out, entry);
        emitted += 1;
    }
    if emitted == 0 {
        out.push_str("// No project models registered.\n");
    }
    out
}

fn emit_interface(out: &mut String, entry: &AdminEntry) {
    out.push_str(&format!(
        "/** {} (`{}`). */\n",
        entry.singular_name, entry.admin_name
    ));
    out.push_str(&format!("export interface {} {{\n", entry.singular_name));
    for f in entry.fields {
        out.push_str(&format!("  {}: {};\n", f.name, ts_type_for(f)));
    }
    out.push_str("}\n\n");
}

/// Map one field's declared `FieldType` to its TypeScript shape.
/// Optional variants emit a `| null` union; non-optional fields
/// stay tight.
fn ts_type_for(field: &AdminField) -> String {
    let base: &str = match field.field_type {
        FieldType::Bool => "boolean",
        FieldType::I32
        | FieldType::I64
        | FieldType::OptionalI64
        | FieldType::String
        | FieldType::OptionalString
        | FieldType::DateTime
        | FieldType::OptionalDateTime
        | FieldType::FilePath
        | FieldType::OptionalFilePath => match field.field_type {
            FieldType::I32 | FieldType::I64 | FieldType::OptionalI64 => "number",
            _ => "string",
        },
    };
    if field.field_type.nullable() {
        format!("{base} | null")
    } else {
        base.to_string()
    }
}

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

    fn f(name: &'static str, field_type: FieldType) -> AdminField {
        AdminField {
            name,
            label: name,
            field_type,
            editable: true,
            relation: None,
            choices: None,
        }
    }

    #[test]
    fn ts_type_for_maps_each_field_type() {
        assert_eq!(ts_type_for(&f("active", FieldType::Bool)), "boolean");
        assert_eq!(ts_type_for(&f("n", FieldType::I32)), "number");
        assert_eq!(ts_type_for(&f("n", FieldType::I64)), "number");
        assert_eq!(
            ts_type_for(&f("n", FieldType::OptionalI64)),
            "number | null"
        );
        assert_eq!(ts_type_for(&f("s", FieldType::String)), "string");
        assert_eq!(
            ts_type_for(&f("s", FieldType::OptionalString)),
            "string | null"
        );
        assert_eq!(ts_type_for(&f("d", FieldType::DateTime)), "string");
        assert_eq!(
            ts_type_for(&f("d", FieldType::OptionalDateTime)),
            "string | null"
        );
        assert_eq!(ts_type_for(&f("p", FieldType::FilePath)), "string");
        assert_eq!(
            ts_type_for(&f("p", FieldType::OptionalFilePath)),
            "string | null"
        );
    }

    #[test]
    fn empty_admin_emits_header_and_no_interfaces() {
        let ts = build_typescript(&Admin::new());
        assert!(
            ts.starts_with("// Auto-generated by rustio-admin for"),
            "missing header: {ts}"
        );
        // Synthetic User is `core: true` → excluded.
        assert!(
            !ts.contains("export interface User"),
            "core User must not leak into the SDK"
        );
        assert!(
            ts.contains("// No project models registered.\n"),
            "expected empty-marker line: {ts}"
        );
    }

    #[test]
    fn header_carries_branded_app_name() {
        let admin = Admin::new().app_name("Acme Clinic");
        let ts = build_typescript(&admin);
        assert!(
            ts.contains("rustio-admin for Acme Clinic"),
            "branded app name must appear in the header"
        );
    }
}