duc 3.2.0

The duc 2D CAD file format Rust implementation.
Documentation
use std::env;
use std::fs;
use std::path::PathBuf;

/// Extract raw `PRAGMA user_version = <N>;` from `duc.sql`.
fn schema_user_version_from_sql(sql: &str) -> u32 {
    for line in sql.lines() {
        let trimmed = line.trim();
        if let Some(rest) = trimmed.strip_prefix("PRAGMA user_version") {
            let rest = rest.trim().trim_start_matches('=').trim().trim_end_matches(';').trim();
            if let Ok(v) = rest.parse::<u32>() {
                return v;
            }
        }
    }
    0
}

/// Convention: 3000000 → "3.0.0" (major * 1_000_000 + minor * 1_000 + patch).
fn decode_user_version_to_semver(user_version: u32) -> String {
    let major = user_version / 1_000_000;
    let minor = (user_version % 1_000_000) / 1_000;
    let patch = user_version % 1_000;
    format!("{major}.{minor}.{patch}")
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
    let schema_dir = manifest_dir.join("..").join("..").join("schema");
    let out_dir = PathBuf::from(env::var("OUT_DIR")?);

    // Copy schema files into OUT_DIR so bootstrap.rs can include_str! them
    // even when the crate is built from an sdist in a temp directory.
    for name in ["duc.sql", "version_control.sql", "search.sql"] {
        let src = schema_dir.join(name);
        let dst = out_dir.join(name);
        match fs::read_to_string(&src) {
            Ok(contents) => fs::write(&dst, contents)?,
            Err(e) => {
                eprintln!("cargo:warning=Could not read {:?}: {e}. Writing empty stub.", src);
                fs::write(&dst, "")?;
            }
        }
        println!("cargo:rerun-if-changed={}", src.display());
    }
    // Scan schema/migrations/*.sql, parse (from, to) from filenames like
    // "3000000_to_3000001.sql", and generate migrations_registry.rs with a
    // sorted static array of (from_version, to_version, sql) tuples.
    // Adding a new migration requires only a new SQL file — no Rust changes.
    let migrations_dir = schema_dir.join("migrations");
    let mut migrations: Vec<(i64, i64, String)> = Vec::new();

    if migrations_dir.is_dir() {
        for entry in fs::read_dir(&migrations_dir)? {
            let entry = entry?;
            let path = entry.path();
            if path.extension().and_then(|e| e.to_str()) != Some("sql") {
                continue;
            }
            let stem = match path.file_stem().and_then(|s| s.to_str()) {
                Some(s) => s.to_owned(),
                None => continue,
            };
            // Expect exactly "<from>_to_<to>".
            if let Some((from_str, to_str)) = stem.split_once("_to_") {
                if let (Ok(from), Ok(to)) = (from_str.parse::<i64>(), to_str.parse::<i64>()) {
                    let sql = fs::read_to_string(&path)?;
                    migrations.push((from, to, sql));
                    println!("cargo:rerun-if-changed={}", path.display());
                } else {
                    eprintln!("cargo:warning=Skipping migration file with non-integer versions: {stem}.sql");
                }
            } else {
                eprintln!("cargo:warning=Skipping migration file with unexpected name: {stem}.sql");
            }
        }
        println!("cargo:rerun-if-changed={}", migrations_dir.display());
    }

    // Sort ascending by from_version so chained migrations apply in order.
    migrations.sort_by_key(|(from, _, _)| *from);

    // Emit migrations_registry.rs: a static array included by bootstrap.rs.
    let mut reg = String::from(
        "// Auto-generated by build.rs — do not edit by hand.\n\
         // Each entry: (from_version, to_version, sql).\n\
         pub(crate) static MIGRATIONS: &[(i64, i64, &str)] = &[\n",
    );
    for (from, to, sql) in &migrations {
        reg.push_str(&format!("    ({from}i64, {to}i64, \""));
        for c in sql.chars() {
            match c {
                '"' => reg.push_str("\\\""),
                '\\' => reg.push_str("\\\\"),
                '\r' => {} // normalise CRLF → LF
                c => reg.push(c),
            }
        }
        reg.push_str("\"),\n");
    }
    reg.push_str("];\n");
    fs::write(out_dir.join("migrations_registry.rs"), reg)?;
    let sql_path = schema_dir.join("duc.sql");
    let (user_version, semver_version) = match fs::read_to_string(&sql_path) {
        Ok(sql) => {
            let uv = schema_user_version_from_sql(&sql);
            (uv, decode_user_version_to_semver(uv))
        }
        Err(e) => {
            eprintln!("cargo:warning=Could not read {:?}: {e}. Defaulting to 0.0.0.", sql_path);
            (0, "0.0.0".to_string())
        }
    };

    // Semver string for human-readable build metadata.
    println!("cargo:rustc-env=DUC_SCHEMA_VERSION={semver_version}");
    // Raw integer for version-control schema comparisons/migrations.
    println!("cargo:rustc-env=DUC_SCHEMA_USER_VERSION={user_version}");

    // Generate a compile-time Rust literal for the current schema version.
    // Included by version_control.rs as a true `const i32`.
    fs::write(
        out_dir.join("schema_user_version.rs"),
        format!("{}i32", user_version),
    )?;

    println!("cargo:rerun-if-changed=build.rs");

    Ok(())
}