syncular-codegen 0.1.0

Schema introspection and Rust code generator for Syncular app clients.
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};

#[test]
fn generated_targets_share_the_same_app_schema_contract() {
    let rust_dir = rust_workspace_dir();
    let example_dir = rust_dir.join("examples/todo-app");
    let schema: Value = serde_json::from_str(
        &fs::read_to_string(example_dir.join("syncular.schema.json")).expect("read schema json"),
    )
    .expect("parse schema json");

    let generated = GeneratedTargets {
        rust_schema: read(&example_dir.join("generated/rust/schema.rs")),
        rust_tables: read(&example_dir.join("generated/rust/diesel_tables.rs")),
        rust_syncular: read(&example_dir.join("generated/rust/syncular.rs")),
        typescript: read(&example_dir.join("generated/typescript/syncular.generated.ts")),
        swift: read(&example_dir.join("generated/swift/SyncularApp.swift")),
        kotlin: read(&example_dir.join("generated/kotlin/SyncularApp.kt")),
        android_kotlin: read(&example_dir.join("generated/kotlin/android/SyncularApp.kt")),
    };

    assert_target_boundaries(&generated);

    let tables = schema["tables"].as_array().expect("schema tables");
    assert!(!tables.is_empty(), "schema must contain app tables");
    for table in tables {
        let table_name = table["name"].as_str().expect("table name");
        let type_name = singular_pascal_case(table_name);
        let primary_key = table["primaryKeyColumn"]
            .as_str()
            .expect("primary key column");
        let server_version = table["serverVersionColumn"]
            .as_str()
            .expect("server version column");

        assert_contains(
            &generated.rust_schema,
            &format!("{} ({})", table_name, primary_key),
            "rust schema table primary key",
        );
        assert_contains(
            &generated.rust_tables,
            &format!("pub struct {type_name}Row"),
            "rust row struct",
        );
        assert_contains(
            &generated.rust_syncular,
            &format!("pub struct New{type_name}"),
            "rust new mutation DTO",
        );
        assert_contains(
            &generated.rust_syncular,
            &format!("pub struct {type_name}Patch"),
            "rust patch mutation DTO",
        );
        assert_contains(
            &generated.rust_syncular,
            &format!("pub struct Delete{type_name}"),
            "rust delete mutation DTO",
        );
        assert_contains(
            &generated.rust_syncular,
            &format!("pub struct {type_name}ChangedRow"),
            "rust typed changed-row helper",
        );
        assert_contains(
            &generated.rust_syncular,
            &format!("pub struct {type_name}ChangedFields"),
            "rust typed changed-field helper",
        );
        assert_contains(
            &generated.rust_syncular,
            &format!("pub fn {table_name}(self)"),
            "rust mutation namespace",
        );

        assert_contains(
            &generated.typescript,
            &format!("{table_name}: {type_name}Row;"),
            "typescript db table mapping",
        );
        assert_contains(
            &generated.typescript,
            &format!("export interface {type_name}Row"),
            "typescript row type",
        );
        assert_contains(
            &generated.typescript,
            &format!("export interface New{type_name}"),
            "typescript new input type",
        );
        assert_contains(
            &generated.typescript,
            &format!("export type {type_name}Patch"),
            "typescript patch type",
        );
        assert_contains(
            &generated.typescript,
            &format!("new{type_name}Operation"),
            "typescript new operation builder",
        );
        assert_contains(
            &generated.typescript,
            &format!("patch{type_name}Operation"),
            "typescript patch operation builder",
        );
        assert_contains(
            &generated.typescript,
            &format!("export type {type_name}ChangedRow"),
            "typescript typed changed-row helper",
        );
        assert_contains(
            &generated.typescript,
            &format!("export const syncular{type_name}ChangedFields"),
            "typescript typed changed-field helper",
        );

        assert_contains(
            &generated.swift,
            &format!("public struct {type_name}Row"),
            "swift row type",
        );
        assert_contains(
            &generated.swift,
            &format!("public struct New{type_name}"),
            "swift new input type",
        );
        assert_contains(
            &generated.swift,
            &format!("public struct {type_name}Patch"),
            "swift patch type",
        );
        assert_contains(
            &generated.swift,
            &format!("public enum {type_name}Query"),
            "swift generated query namespace",
        );
        assert_contains(
            &generated.swift,
            &format!("SyncularQueryTable<{type_name}Row>"),
            "swift generated query table descriptor",
        );
        assert_contains(
            &generated.swift,
            &format!("public static func select() -> SyncularSelectQuery<{type_name}Row>"),
            "swift generated query select builder",
        );
        assert_contains(
            &generated.swift,
            &format!("public static func new{type_name}"),
            "swift operation builder",
        );
        assert_contains(
            &generated.swift,
            &format!("public struct {type_name}Mutations"),
            "swift mutation namespace",
        );
        assert_contains(
            &generated.swift,
            &format!("public var {table_name}: {type_name}Mutations"),
            "swift table mutation namespace",
        );
        assert_contains(
            &generated.swift,
            &format!("public func insert(_ input: New{type_name}"),
            "swift insert helper",
        );
        assert_contains(
            &generated.swift,
            &format!("public struct {type_name}ChangedRow"),
            "swift typed changed-row helper",
        );
        assert_contains(
            &generated.swift,
            &format!("public struct {type_name}ChangedFields"),
            "swift typed changed-field helper",
        );

        for kotlin in [&generated.kotlin, &generated.android_kotlin] {
            assert_contains(
                kotlin,
                &format!("data class {type_name}Row"),
                "kotlin row type",
            );
            assert_contains(
                kotlin,
                &format!("data class New{type_name}"),
                "kotlin new input type",
            );
            assert_contains(
                kotlin,
                &format!("data class {type_name}Patch"),
                "kotlin patch type",
            );
            assert_contains(
                kotlin,
                &format!("object {type_name}Query"),
                "kotlin generated query namespace",
            );
            assert_contains(
                kotlin,
                &format!("SyncularQueryTable(name = \"{table_name}\""),
                "kotlin generated query table descriptor",
            );
            assert_contains(
                kotlin,
                &format!("fun select(): SyncularSelectQuery<{type_name}Row>"),
                "kotlin generated query select builder",
            );
            assert_contains(
                kotlin,
                &format!("fun new{type_name}"),
                "kotlin operation builder",
            );
            assert_contains(
                kotlin,
                &format!("class {type_name}Mutations"),
                "kotlin mutation namespace",
            );
            assert_contains(
                kotlin,
                &format!("val {table_name}: {type_name}Mutations"),
                "kotlin table mutation namespace",
            );
            assert_contains(
                kotlin,
                &format!("fun insert(input: New{type_name}"),
                "kotlin insert helper",
            );
            assert_contains(
                kotlin,
                &format!("data class {type_name}ChangedRow"),
                "kotlin typed changed-row helper",
            );
            assert_contains(
                kotlin,
                &format!("data class {type_name}ChangedFields"),
                "kotlin typed changed-field helper",
            );
        }

        assert_contains(
            &generated.typescript,
            &format!("serverVersionColumn: '{server_version}'"),
            "typescript table metadata",
        );
        assert_contains(
            &generated.rust_syncular,
            &format!("server_version_column: \"{server_version}\""),
            "rust table metadata",
        );
    }
}

fn assert_target_boundaries(generated: &GeneratedTargets) {
    for source in [
        &generated.swift,
        &generated.kotlin,
        &generated.android_kotlin,
    ] {
        assert_contains(
            source,
            "queryJson",
            "native generated read path must use queryJson",
        );
        assert_contains(
            source,
            "applyMutationJson",
            "native generated write path must use applyMutationJson",
        );
        assert_contains(
            source,
            "registerQueryJson",
            "native generated live queries must register through generic query observer",
        );
        assert_contains(
            source,
            "SyncularSelectQuery",
            "native generated app modules must expose a query-builder adapter",
        );
        assert!(
            !source.contains("listTasks"),
            "native generated app modules must not expose predefined task reads"
        );
        assert!(
            !source.contains("TASKS_TABLE"),
            "generated modules must not expose table constants as the query API"
        );
    }
}

struct GeneratedTargets {
    rust_schema: String,
    rust_tables: String,
    rust_syncular: String,
    typescript: String,
    swift: String,
    kotlin: String,
    android_kotlin: String,
}

fn rust_workspace_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("rust workspace")
        .to_path_buf()
}

fn read(path: &Path) -> String {
    fs::read_to_string(path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()))
}

fn assert_contains(source: &str, needle: &str, label: &str) {
    assert!(
        source.contains(needle),
        "{label} missing expected snippet: {needle}"
    );
}

fn singular_pascal_case(table: &str) -> String {
    let singular = table.strip_suffix('s').unwrap_or(table);
    singular
        .split('_')
        .filter(|part| !part.is_empty())
        .map(|part| {
            let mut chars = part.chars();
            match chars.next() {
                Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
                None => String::new(),
            }
        })
        .collect()
}