jerrycan 0.6.27

The AI-native Rust backend platform: framework, CLI, and MCP server. https://jerrycan.cc
Documentation
//! Scaffold + mounting determinism. Everything here is fast (no cargo builds).

use jerrycan::platform::design::Design;
use jerrycan::platform::{mounting, scaffold};
use std::fs;

const DESIGN: &str = include_str!("../../../conformance/designs/todo-api.design.json");

fn design() -> Design {
    serde_json::from_str(DESIGN).unwrap()
}

#[test]
fn scaffold_creates_the_fractal_workspace() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().join("todo-api");
    let created = scaffold::scaffold(&root, &design()).unwrap();

    for expected in [
        "Cargo.toml",
        "jerrycan.toml",
        "design.json",
        ".gitignore",
        "crates/app/Cargo.toml",
        "crates/app/src/main.rs",
        "crates/shared/Cargo.toml",
        "crates/shared/src/lib.rs",
        "crates/routes/todos/Cargo.toml",
        "crates/routes/todos/src/lib.rs",
        "crates/routes/todos/src/handlers.rs",
        "crates/routes/todos/src/model.rs",
        "crates/routes/todos/src/repo.rs",
        "crates/routes/todos/src/deps.rs",
        "crates/routes/todos/src/subroutes/comments/mod.rs",
        "crates/routes/users/src/lib.rs",
    ] {
        assert!(
            root.join(expected).exists(),
            "missing {expected}; created={created:?}"
        );
    }
}

#[test]
fn mounting_is_sorted_and_idempotent() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().join("todo-api");
    scaffold::scaffold(&root, &design()).unwrap();

    let main1 = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
    assert!(main1.contains("GENERATED by jerrycan"));
    let todos_pos = main1
        .find(".mount(\"/todos\", route_todos::module())")
        .unwrap();
    let users_pos = main1
        .find(".mount(\"/users\", route_users::module())")
        .unwrap();
    assert!(
        todos_pos < users_pos,
        "mounts must be sorted by module name"
    );

    let ws1 = fs::read_to_string(root.join("Cargo.toml")).unwrap();
    assert!(ws1.contains("\"crates/routes/todos\","));

    // Regenerating changes nothing — byte-identical (determinism contract).
    mounting::regenerate(&root, &design()).unwrap();
    let main2 = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
    let ws2 = fs::read_to_string(root.join("Cargo.toml")).unwrap();
    assert_eq!(main1, main2);
    assert_eq!(ws1, ws2);
}

#[test]
fn regenerate_shrinks_cleanly_when_a_module_is_removed() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().join("todo-api");
    let mut d = design();
    scaffold::scaffold(&root, &d).unwrap();
    d.modules.retain(|m| m.name != "users");
    mounting::regenerate(&root, &d).unwrap();
    let main_rs = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
    assert!(!main_rs.contains("route_users"), "stale mount removed");
    let ws = fs::read_to_string(root.join("Cargo.toml")).unwrap();
    assert!(!ws.contains("crates/routes/users"), "stale member removed");
    assert!(ws.contains("# jerrycan:members:begin") && ws.contains("# jerrycan:members:end"));
    let app_cargo = fs::read_to_string(root.join("crates/app/Cargo.toml")).unwrap();
    assert!(
        !app_cargo.contains("route-users"),
        "stale route-dep removed"
    );
    // idempotent after shrink
    mounting::regenerate(&root, &d).unwrap();
    assert_eq!(
        main_rs,
        fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap()
    );
}

#[test]
fn scaffold_refuses_a_nonempty_target() {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().join("busy");
    fs::create_dir_all(root.join("stuff")).unwrap();
    let err = scaffold::scaffold(&root, &design()).unwrap_err();
    assert!(err.contains("not empty"), "{err}");
}

#[test]
fn expected_main_matches_what_regenerate_writes() {
    // JL0003 (generated-drift lint) depends on this equivalence.
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().join("todo-api");
    scaffold::scaffold(&root, &design()).unwrap();
    let on_disk = fs::read_to_string(root.join("crates/app/src/main.rs")).unwrap();
    assert_eq!(on_disk, mounting::expected_main(&design()));
}

/// #112 (write_only) end-to-end at the scaffold layer: a db-mode design with a
/// `write_only` `api_token` (and a `password_hash` auto-hidden by name) is run
/// through the REAL scaffolder, and the generated `model.rs` on disk carries
/// `#[serde(skip_serializing)]` on exactly those two columns — never on
/// `id`/`email`. A serde round-trip against a struct carrying the very attribute
/// the generator emitted proves the load-bearing property the hide rests on: the
/// field is OMITTED from a serialized response yet still ACCEPTED on input (the
/// no-DTO create body is `Json<Account>` = the Model, which must deserialize it).
/// This is the fast conformance proof (no cargo build); the heavy live-HTTP
/// variants live in `tests/conformance.rs`.
#[test]
fn write_only_columns_are_response_hidden_in_the_scaffolded_model() {
    const HIDDEN: &str = r#"{
        "name": "secrets-api", "contract_version": 1,
        "dependencies": ["db"],
        "modules": [{ "name": "accounts",
            "entities": [{ "name": "Account", "fields": [
                { "name": "id", "type": "integer" },
                { "name": "email", "type": "string" },
                { "name": "api_token", "type": "string", "write_only": true },
                { "name": "password_hash", "type": "string" } ] }],
            "endpoints": [
                { "operation_id": "create_account", "method": "POST", "path": "/",
                  "request_body": { "entity": "Account" },
                  "success": { "status": 201, "entity": "Account" } },
                { "operation_id": "get_account", "method": "GET", "path": "/{id}",
                  "success": { "status": 200, "entity": "Account" } } ] }] }"#;
    let d: Design = serde_json::from_str(HIDDEN).unwrap();
    // The design validates clean — write_only is accepted on a non-id field, and
    // there is no realtime `changes` entity to project.
    let qs = jerrycan::platform::questions::validate(&d);
    assert!(qs.is_empty(), "write_only db design must validate: {qs:?}");

    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().join("secrets-api");
    scaffold::scaffold(&root, &d).unwrap();
    let model = fs::read_to_string(root.join("crates/routes/accounts/src/model.rs")).unwrap();

    // Exactly the two secret columns are response-hidden — api_token (explicit)
    // and password_hash (auto-classified) — and no other field is.
    assert_eq!(
        model.matches("#[serde(skip_serializing)]").count(),
        2,
        "exactly api_token + password_hash are skip_serializing: {model}"
    );
    for hidden in ["api_token", "password_hash"] {
        assert!(
            model.contains(&format!("#[serde(skip_serializing)]\n        pub {hidden}")),
            "`{hidden}` must carry skip_serializing: {model}"
        );
    }
    for shown in ["id", "email"] {
        assert!(
            !model.contains(&format!("#[serde(skip_serializing)]\n        pub {shown}")),
            "`{shown}` must NOT be hidden: {model}"
        );
    }
    // The Model still derives Deserialize — the no-DTO create body deserializes
    // the hidden columns (input is unaffected; only the response omits them).
    assert!(
        model.contains("Deserialize"),
        "the Model must still deserialize (input path intact): {model}"
    );

    // The load-bearing property, made executable against the emitted attribute:
    // a response OMITS the write_only column while a create body still populates
    // it. (Uses the exact `#[serde(skip_serializing)]` the scaffolder wrote.)
    #[derive(serde::Serialize, serde::Deserialize)]
    struct Account {
        id: i64,
        email: String,
        #[serde(skip_serializing)]
        api_token: String,
    }
    let created: Account =
        serde_json::from_str(r#"{ "id": 1, "email": "a@b.c", "api_token": "sk-secret" }"#).unwrap();
    assert_eq!(
        created.api_token, "sk-secret",
        "input accepts the write_only field"
    );
    let body = serde_json::to_string(&created).unwrap();
    assert!(
        !body.contains("api_token"),
        "response hides the write_only field: {body}"
    );
    assert!(
        body.contains("a@b.c"),
        "non-hidden fields still serialize: {body}"
    );
}