romance-core 0.2.5

Core library for Romance CLI code generation
Documentation
use crate::generator::context::{self, markers};
use crate::relation;
use crate::template::TemplateEngine;
use crate::utils;
use anyhow::Result;
use heck::{ToPascalCase, ToSnakeCase};
use std::path::Path;
use tera::Context;

/// Generate a junction table for a many-to-many relation between two entities.
/// Creates:
/// 1. Junction entity model (backend/src/entities/{junction}.rs)
/// 2. Junction migration (backend/migration/src/m{ts}_create_{junction}_table.rs)
/// 3. Registers junction mod in entities/mod.rs
/// 4. Registers junction migration in migration/src/lib.rs
/// 5. Injects Related<T> via junction into both entities (if they exist)
/// 6. Injects M2M handlers + routes into the source entity
/// 7. If target entity exists, injects M2M handlers + routes into target too
pub fn generate(
    source_entity: &str,
    target_entity: &str,
) -> Result<()> {
    let engine = TemplateEngine::new()?;
    let junction = relation::junction_name(source_entity, target_entity);
    let junction_snake = junction.to_snake_case();

    let base = Path::new("backend/src");
    let project_root = Path::new(".");

    // If target entity doesn't exist, store as pending and return
    if !relation::entity_exists(project_root, target_entity) {
        println!(
            "  Target entity '{}' not found — storing pending M2M relation",
            target_entity
        );
        relation::store_pending(
            project_root,
            relation::PendingRelation {
                source_entity: source_entity.to_string(),
                target_entity: target_entity.to_string(),
                relation_type: "ManyToMany".to_string(),
            },
        )?;
        return Ok(());
    }

    // Check if junction already exists (idempotency / circular M2M protection)
    let junction_path = base.join(format!("entities/{}.rs", junction_snake));
    if junction_path.exists() {
        println!("  Junction entity '{}' already exists, skipping", junction);
        // Still inject relations if needed
        inject_m2m_into_entity(&engine, source_entity, target_entity, &junction)?;
        if relation::entity_exists(project_root, target_entity) {
            inject_m2m_into_entity(&engine, target_entity, source_entity, &junction)?;
        }
        return Ok(());
    }

    // Determine alphabetical ordering (junction naming convention)
    let (entity_a, entity_b) = if source_entity.to_snake_case() < target_entity.to_snake_case() {
        (source_entity, target_entity)
    } else {
        (target_entity, source_entity)
    };

    let entity_a_snake = entity_a.to_snake_case();
    let entity_b_snake = entity_b.to_snake_case();

    // Build context for junction templates
    let mut ctx = Context::new();
    ctx.insert("junction_table", &format!("{}_{}", entity_a_snake, entity_b_snake));
    ctx.insert("junction_snake", &junction_snake);
    ctx.insert("junction_iden", &junction.to_pascal_case());
    ctx.insert("entity_a", &entity_a.to_pascal_case());
    ctx.insert("entity_b", &entity_b.to_pascal_case());
    ctx.insert("entity_a_snake", &entity_a_snake);
    ctx.insert("entity_b_snake", &entity_b_snake);
    ctx.insert("entity_a_table", &utils::pluralize(&entity_a_snake));
    ctx.insert("entity_b_table", &utils::pluralize(&entity_b_snake));

    // 1. Generate junction model
    let model_content = engine.render("entity/backend/junction_model.rs.tera", &ctx)?;
    utils::write_file(&junction_path, &model_content)?;

    // 2. Register junction mod in entities/mod.rs
    let entities_mod = base.join("entities/mod.rs");
    utils::insert_at_marker(
        &entities_mod,
        markers::MODS,
        &format!("pub mod {};", junction_snake),
    )?;

    // 3. Generate junction migration
    let timestamp = super::migration::next_timestamp();
    let migration_content = engine.render("entity/backend/junction_migration.rs.tera", &ctx)?;
    let migration_module = format!("m{}_create_{}_table", timestamp, junction_snake);
    let migration_path =
        Path::new("backend/migration/src").join(format!("{}.rs", migration_module));
    utils::write_file(&migration_path, &migration_content)?;

    // 4. Register migration in lib.rs
    context::register_migration(Path::new("."), &migration_module)?;

    // 5. Inject Related<T> via junction + M2M handlers/routes into source entity
    inject_m2m_into_entity(&engine, source_entity, target_entity, &junction)?;

    // 6. If target entity exists, inject reverse M2M into it too
    if relation::entity_exists(project_root, target_entity) {
        inject_m2m_into_entity(&engine, target_entity, source_entity, &junction)?;
    }

    println!(
        "  Generated M2M junction: {} <-> {} (via {})",
        source_entity, target_entity, junction
    );
    Ok(())
}

/// Delimiter used to split multi-part template output into individual sections
/// for separate `insert_at_marker()` calls (preserving per-section idempotency).
const TEMPLATE_SPLIT_DELIMITER: &str = "\n---ROMANCE_SPLIT---\n";

/// Inject M2M relation code into an entity:
/// - Related<target::Entity> via junction into model
/// - M2M handlers (list/add/remove) into handlers
/// - M2M routes into routes
fn inject_m2m_into_entity(
    engine: &TemplateEngine,
    entity: &str,
    related: &str,
    junction: &str,
) -> Result<()> {
    let base = Path::new("backend/src");
    let entity_snake = entity.to_snake_case();
    let related_snake = related.to_snake_case();
    let junction_snake = junction.to_snake_case();

    // 1. Inject Related<related::Entity> via junction into entity model
    let model_path = base.join(format!("entities/{}.rs", entity_snake));
    if model_path.exists() {
        let related_impl = format!(
            r#"impl Related<super::{}::Entity> for Entity {{
    fn to() -> RelationDef {{
        super::{}::Relation::{}.def()
    }}
    fn via() -> Option<RelationDef> {{
        Some(super::{}::Relation::{}.def().rev())
    }}
}}"#,
            related_snake,
            junction_snake,
            related.to_pascal_case(),
            junction_snake,
            entity.to_pascal_case(),
        );
        utils::insert_at_marker(
            &model_path,
            markers::RELATIONS,
            &related_impl,
        )?;
    }

    // 2. Inject M2M handlers into entity handlers via Tera template
    let handlers_path = base.join(format!("handlers/{}.rs", entity_snake));
    if handlers_path.exists() {
        let related_plural = utils::pluralize(&related_snake);

        let mut ctx = Context::new();
        ctx.insert("entity_name", &entity.to_pascal_case());
        ctx.insert("entity_snake", &entity_snake);
        ctx.insert("related_name", &related.to_pascal_case());
        ctx.insert("related_snake", &related_snake);
        ctx.insert("related_plural", &related_plural);
        ctx.insert("junction_snake", &junction_snake);

        let rendered = engine.render("entity/backend/m2m_handlers.rs.tera", &ctx)?;
        let parts: Vec<&str> = rendered.split(TEMPLATE_SPLIT_DELIMITER).collect();

        for part in &parts {
            let trimmed = part.trim_end_matches('\n');
            utils::insert_at_marker(
                &handlers_path,
                markers::RELATION_HANDLERS,
                trimmed,
            )?;
        }
    }

    // 3. Inject M2M routes into entity routes via Tera template
    let routes_path = base.join(format!("routes/{}.rs", entity_snake));
    if routes_path.exists() {
        let project_root = Path::new(".");
        let config = crate::config::RomanceConfig::load(project_root).ok();
        let api_prefix = config.as_ref()
            .and_then(|c| c.backend.api_prefix.clone())
            .unwrap_or_else(|| "/api".to_string());

        let entity_plural = utils::pluralize(&entity_snake);
        let related_plural = utils::pluralize(&related_snake);

        let mut ctx = Context::new();
        ctx.insert("api_prefix", &api_prefix);
        ctx.insert("entity_snake", &entity_snake);
        ctx.insert("entity_plural", &entity_plural);
        ctx.insert("related_snake", &related_snake);
        ctx.insert("related_plural", &related_plural);
        // Pre-computed route parameter: e.g., "{tag_id}" — avoids Tera brace escaping issues
        ctx.insert("related_id_param", &format!("{{{}_id}}", related_snake));

        let rendered = engine.render("entity/backend/m2m_routes.rs.tera", &ctx)?;
        let parts: Vec<&str> = rendered.split(TEMPLATE_SPLIT_DELIMITER).collect();

        for part in &parts {
            let trimmed = part.trim_end_matches('\n');
            utils::insert_at_marker(
                &routes_path,
                markers::RELATION_ROUTES,
                trimmed,
            )?;
        }
    }

    Ok(())
}