romcal 4.0.0-beta.6

Core Rust library for calculating Catholic liturgical dates and calendars
Documentation
use std::collections::HashSet;
use std::fs;
use std::process::Command;

// Import shared utilities
include!("src/engine/data_tree_builder.rs");

// Constants for paths
const CALENDARS_DIR: &str = "../data/definitions";
const RESOURCES_DIR: &str = "../data/resources";
const CALENDAR_IDS_FILE: &str = "src/generated/calendar_ids.rs";
const LOCALE_IDS_FILE: &str = "src/generated/locale_ids.rs";

fn generate_constants() {
    println!("cargo:info=Generating constants from JSON files...");

    // Collect calendar information (ID and parent relationships)
    let mut calendar_infos = Vec::new();
    collect_calendar_infos_recursively(CALENDARS_DIR, &mut calendar_infos);

    // Collect locales from JSON "locale" property
    let mut locales = HashSet::new();
    collect_locale_codes_recursively(RESOURCES_DIR, &mut locales);

    // Extract calendar IDs and sort them
    let mut calendar_ids: Vec<String> = calendar_infos.iter().map(|info| info.id.clone()).collect();
    calendar_ids.sort();

    // Convert locales to sorted vector
    let mut locales: Vec<String> = locales.into_iter().collect();
    locales.sort();

    // Build calendar tree
    let calendar_tree = build_calendar_tree(&calendar_infos);

    // Build locale tree
    let locale_tree = build_locale_tree(&locales);

    // Generate the constants file
    let calendar_constants = calendar_ids
        .iter()
        .map(|id| format!("    \"{}\",", id))
        .collect::<Vec<_>>()
        .join("\n");

    let locale_constants = locales
        .iter()
        .map(|locale| format!("    \"{}\",", locale))
        .collect::<Vec<_>>()
        .join("\n");

    // Generate calendar tree constant
    let calendar_tree_constant = generate_calendar_tree_constant(&calendar_tree);

    // Generate locale tree constant
    let locale_tree_constant = generate_locale_tree_constant(&locale_tree);

    // Generate calendar_ids.rs
    let mut calendar_content = String::new();
    calendar_content.push_str("//! Auto-generated calendar constants - Do not modify manually\n");
    calendar_content.push_str("//! This file is generated by build.rs\n\n");

    calendar_content.push_str("/// List of all calendar IDs\n");
    calendar_content.push_str("///\n");
    calendar_content.push_str("/// This constant contains all calendar IDs\n");
    calendar_content.push_str("/// in the romcal project, sorted alphabetically.\n");
    calendar_content.push_str("pub const CALENDAR_IDS: &[&str] = &[\n");
    calendar_content.push_str(&calendar_constants);
    calendar_content.push_str("\n\n];\n\n");

    calendar_content.push_str("/// Calendar tree structure as JSON\n");
    calendar_content.push_str("///\n");
    calendar_content.push_str("/// This constant contains the hierarchical structure\n");
    calendar_content.push_str("/// of all calendars in the romcal project as a JSON string.\n");
    calendar_content.push_str("pub const CALENDAR_TREE_JSON: &str = r#\"");
    calendar_content.push_str(&calendar_tree_constant);
    calendar_content.push_str("\"#;\n");

    fs::write(CALENDAR_IDS_FILE, calendar_content).expect("Failed to write calendar_ids.rs");
    format_generated_file(CALENDAR_IDS_FILE);

    // Generate locale_ids.rs
    let mut locale_content = String::new();
    locale_content.push_str("//! Auto-generated locale constants - Do not modify manually\n");
    locale_content.push_str("//! This file is generated by build.rs\n\n");

    locale_content.push_str("/// List of all locale codes\n");
    locale_content.push_str("///\n");
    locale_content.push_str("/// This constant contains all locale codes\n");
    locale_content.push_str("/// in the romcal project, sorted alphabetically.\n");
    locale_content.push_str("pub const LOCALE_CODES: &[&str] = &[\n");
    locale_content.push_str(&locale_constants);
    locale_content.push_str("\n\n];\n\n");

    locale_content.push_str("/// Locale tree structure as JSON\n");
    locale_content.push_str("///\n");
    locale_content.push_str("/// This constant contains the hierarchical structure\n");
    locale_content.push_str("/// of all locales in the romcal project as a JSON string.\n");
    locale_content
        .push_str("/// The tree follows BCP 47 structure with base languages as parents\n");
    locale_content.push_str("/// and specific locales (e.g., en-gb) as children.\n");
    locale_content.push_str("pub const LOCALE_TREE_JSON: &str = r#\"");
    locale_content.push_str(&locale_tree_constant);
    locale_content.push_str("\"#;\n");

    fs::write(LOCALE_IDS_FILE, locale_content).expect("Failed to write locale_ids.rs");
    format_generated_file(LOCALE_IDS_FILE);

    println!(
        "cargo:info=Constants generated successfully: {} calendars, {} locales",
        calendar_ids.len(),
        locales.len()
    );
}

fn collect_calendar_infos_recursively(dir: &str, infos: &mut Vec<CalendarInfo>) {
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                // Recursively search subdirectories
                if let Some(dir_str) = path.to_str() {
                    collect_calendar_infos_recursively(dir_str, infos);
                }
            } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
                && name.ends_with(".json")
                && let Ok(content) = fs::read_to_string(&path)
                && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
                && let Some(id) = json.get("id").and_then(|v| v.as_str())
            {
                let mut parent_calendar_ids = json
                    .get("parent_calendar_ids")
                    .and_then(|v| v.as_array())
                    .map(|arr| {
                        arr.iter()
                            .filter_map(|v| v.as_str())
                            .map(|s| s.to_string())
                            .collect::<Vec<String>>()
                    })
                    .unwrap_or_default();

                // Add "general_roman" as first parent systematically, except if the current calendar is already general_roman
                if id != "general_roman" {
                    parent_calendar_ids.insert(0, "general_roman".to_string());
                }

                infos.push(CalendarInfo {
                    id: id.to_string(),
                    parent_calendar_ids,
                });
            }
        }
    }
}

fn collect_locale_codes_recursively(dir: &str, locales: &mut HashSet<String>) {
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                // Recursively search subdirectories
                if let Some(dir_str) = path.to_str() {
                    collect_locale_codes_recursively(dir_str, locales);
                }
            } else if let Some(name) = path.file_name().and_then(|n| n.to_str())
                && name.ends_with(".json")
                && let Ok(content) = fs::read_to_string(&path)
                && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
                && let Some(locale) = json.get("locale").and_then(|v| v.as_str())
            {
                locales.insert(locale.to_string());
            }
        }
    }
}

fn format_generated_file(file_path: &str) {
    println!("cargo:info=Formatting generated file with rustfmt...");

    let output = Command::new("rustfmt").arg(file_path).output();

    match output {
        Ok(output) => {
            if output.status.success() {
                println!("cargo:info=File formatted with rustfmt");
            } else {
                println!(
                    "cargo:warning=Warning: rustfmt failed: {}",
                    String::from_utf8_lossy(&output.stderr)
                );
            }
        }
        Err(_) => {
            println!("cargo:warning=Warning: rustfmt not found, skipping formatting");
        }
    }
}

/// Generate bundled data constants when the `bundled-data` feature is enabled.
/// This embeds all calendar definitions and resources as compile-time constants.
#[cfg(feature = "bundled-data")]
fn generate_bundled_data() {
    use std::env;
    use std::path::Path;

    let out_dir = env::var("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("bundled_data.rs");

    let mut content = String::new();
    content.push_str("// Auto-generated bundled data - Do not modify manually\n");
    content
        .push_str("// This file is generated by build.rs when bundled-data feature is enabled\n\n");

    let mut definition_const_names = Vec::new();
    let mut locale_info: Vec<(String, Vec<String>)> = Vec::new();

    // Generate calendar definitions module
    content.push_str("/// Embedded calendar definitions as JSON strings\n");
    content.push_str("pub mod definitions {\n");

    let calendars_path = Path::new(CALENDARS_DIR);
    if calendars_path.exists() {
        generate_definitions_module(
            &mut content,
            calendars_path,
            "    ",
            &mut definition_const_names,
        );
    }

    content.push_str("}\n\n");

    // Generate resources module
    content.push_str("/// Embedded locale resources as JSON strings\n");
    content.push_str("pub mod resources {\n");

    let resources_path = Path::new(RESOURCES_DIR);
    if resources_path.exists() {
        generate_resources_module(&mut content, resources_path, "    ", &mut locale_info);
    }

    content.push_str("}\n\n");

    // Generate helper function to get all definitions
    content.push_str("/// Get all bundled calendar definition JSON strings\n");
    content.push_str("pub fn get_all_definition_jsons() -> Vec<&'static str> {\n");
    content.push_str("    vec![\n");
    for name in &definition_const_names {
        content.push_str(&format!("        definitions::{},\n", name));
    }
    content.push_str("    ]\n");
    content.push_str("}\n\n");

    // Generate helper function to get all resources by locale
    content.push_str("/// Get all bundled resource JSON strings grouped by locale\n");
    content
        .push_str("pub fn get_all_resource_jsons() -> Vec<(&'static str, Vec<&'static str>)> {\n");
    content.push_str("    vec![\n");
    for (locale, file_names) in &locale_info {
        let module_name = locale.replace('-', "_");
        content.push_str(&format!("        (\"{}\", vec![\n", locale));
        for name in file_names {
            content.push_str(&format!(
                "            resources::{}::{},\n",
                module_name, name
            ));
        }
        content.push_str("        ]),\n");
    }
    content.push_str("    ]\n");
    content.push_str("}\n");

    fs::write(&dest_path, content).expect("Failed to write bundled_data.rs");
    format_generated_file(dest_path.to_str().unwrap());

    println!(
        "cargo:info=Bundled data generated successfully ({} definitions, {} locales)",
        definition_const_names.len(),
        locale_info.len()
    );
}

/// Generate definitions module content by recursively scanning calendar directories
#[cfg(feature = "bundled-data")]
fn generate_definitions_module(
    content: &mut String,
    dir: &std::path::Path,
    indent: &str,
    const_names: &mut Vec<String>,
) {
    // Collect all JSON files recursively
    let mut json_files = Vec::new();
    collect_json_files_recursively(dir, &mut json_files);
    json_files.sort();

    for file_path in json_files {
        if let Ok(json_content) = fs::read_to_string(&file_path)
            && let Ok(json) = serde_json::from_str::<serde_json::Value>(&json_content)
            && let Some(id) = json.get("id").and_then(|v| v.as_str())
        {
            let const_name = id.to_uppercase().replace(['-', '.'], "_");
            // Path is already relative to CARGO_MANIFEST_DIR (e.g., ../data/definitions/...)
            let rel_path_str = file_path.to_string_lossy().replace('\\', "/");

            content.push_str(&format!("{}/// Calendar definition: {}\n", indent, id));
            content.push_str(&format!(
                "{}pub const {}: &str = include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{}\"));\n",
                indent, const_name, rel_path_str
            ));

            const_names.push(const_name);
        }
    }
}

/// Recursively collect all JSON files in a directory
#[cfg(feature = "bundled-data")]
fn collect_json_files_recursively(dir: &std::path::Path, files: &mut Vec<std::path::PathBuf>) {
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                collect_json_files_recursively(&path, files);
            } else if path.extension().is_some_and(|ext| ext == "json") {
                files.push(path);
            }
        }
    }
}

/// Generate resources module content by scanning locale directories
#[cfg(feature = "bundled-data")]
fn generate_resources_module(
    content: &mut String,
    dir: &std::path::Path,
    indent: &str,
    locale_info: &mut Vec<(String, Vec<String>)>,
) {
    if let Ok(entries) = fs::read_dir(dir) {
        let mut entries: Vec<_> = entries.flatten().collect();
        entries.sort_by_key(|e| e.path());

        for entry in entries {
            let path = entry.path();
            if path.is_dir()
                && let Some(locale_name) = path.file_name().and_then(|n| n.to_str())
            {
                let module_name = locale_name.replace('-', "_");
                content.push_str(&format!(
                    "{}/// Resources for locale: {}\n",
                    indent, locale_name
                ));
                content.push_str(&format!("{}pub mod {} {{\n", indent, module_name));

                let mut file_const_names = Vec::new();

                // Collect all JSON files in this locale directory
                if let Ok(files) = fs::read_dir(&path) {
                    let mut files: Vec<_> = files.flatten().collect();
                    files.sort_by_key(|e| e.path());

                    for file in files {
                        let file_path = file.path();
                        if file_path.extension().is_some_and(|ext| ext == "json")
                            && let Some(file_name) = file_path.file_stem().and_then(|n| n.to_str())
                        {
                            let const_name = file_name.to_uppercase().replace(['-', '.'], "_");
                            // Path is already relative to CARGO_MANIFEST_DIR
                            let rel_path_str = file_path.to_string_lossy().replace('\\', "/");

                            content.push_str(&format!(
                                "{}    pub const {}: &str = include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{}\"));\n",
                                indent, const_name, rel_path_str
                            ));

                            file_const_names.push(const_name);
                        }
                    }
                }

                content.push_str(&format!("{}}}\n", indent));
                locale_info.push((locale_name.to_string(), file_const_names));
            }
        }
    }
}

fn main() {
    // Only regenerate constants if data directories exist (development environment).
    // When building from crates.io, these directories won't exist, but the generated
    // files are already included in the package.
    if std::path::Path::new(CALENDARS_DIR).exists() && std::path::Path::new(RESOURCES_DIR).exists()
    {
        generate_constants();

        #[cfg(feature = "bundled-data")]
        generate_bundled_data();

        println!("cargo:rerun-if-changed={}", CALENDARS_DIR);
        println!("cargo:rerun-if-changed={}", RESOURCES_DIR);
    }
}