async-cffi-codegen 0.1.1

Logic for generating CFFI bindings for asynchronous Rust code.
Documentation
use std::{
    collections::HashMap,
    fs,
    io::{Error, ErrorKind, Result, Write as _},
    path::Path,
    process::{Command, Stdio},
};

use rs_schema::TraitSchema;

use crate::{CodeGen as _, trait_to_async_cffi_py_schema, trait_to_async_cffi_rs_schema};

pub fn generate_rs(
    ios_trait_schema: &TraitSchema,
    target_path: &Path,
    supertrait_schemas: &HashMap<String, TraitSchema>,
) -> Result<()> {
    let file_schema = trait_to_async_cffi_rs_schema(ios_trait_schema, supertrait_schemas)
        .map_err(|m| Error::new(ErrorKind::Other, m))?;
    let new_contents = file_schema.codegen(0);
    let formatted_new_contents = format_rust_code(&new_contents).unwrap_or_else(|err| {
        println!("cargo:warning=Failed to format generated code: {err}");
        new_contents
    });
    write_if_changed(&target_path, &formatted_new_contents)
}

fn format_rust_code(code: &str) -> Result<String> {
    let edition = std::env::var("CARGO_PKG_EDITION").unwrap_or_else(|err| {
        println!(
            "cargo:warning=Using default edition 2024 for rustfmt (CARGO_PKG_EDITION missing: {err})"
        );
        "2024".to_string()
    });

    let mut process = Command::new("rustfmt")
        .args(["--edition", &edition, "--emit", "stdout"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .map_err(|e| Error::new(ErrorKind::Other, format!("failed to spawn rustfmt: {e}")))?;

    if let Some(mut stdin) = process.stdin.take() {
        stdin.write_all(code.as_bytes()).map_err(|e| {
            Error::new(
                ErrorKind::Other,
                format!("failed to write to rustfmt stdin: {e}"),
            )
        })?;
    }

    let output = process.wait_with_output().map_err(|e| {
        Error::new(
            ErrorKind::Other,
            format!("failed to read rustfmt output: {e}"),
        )
    })?;

    if !output.status.success() {
        return Err(Error::new(
            ErrorKind::Other,
            format!(
                "rustfmt exited with status {}: {}",
                output.status,
                String::from_utf8_lossy(&output.stderr)
            ),
        ));
    }

    String::from_utf8(output.stdout).map_err(|e| {
        Error::new(
            ErrorKind::Other,
            format!("rustfmt produced invalid UTF-8: {e}"),
        )
    })
}

fn format_python_code(code: &str) -> Result<String> {
    let mut process = Command::new("python")
        .args(["-m", "black", "-"])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .map_err(|e| Error::new(ErrorKind::Other, format!("failed to spawn black: {e}")))?;

    if let Some(mut stdin) = process.stdin.take() {
        stdin.write_all(code.as_bytes()).map_err(|e| {
            Error::new(
                ErrorKind::Other,
                format!("failed to write to black stdin: {e}"),
            )
        })?;
    }

    let output = process.wait_with_output().map_err(|e| {
        Error::new(
            ErrorKind::Other,
            format!("failed to read black output: {e}"),
        )
    })?;

    if !output.status.success() {
        return Err(Error::new(
            ErrorKind::Other,
            format!(
                "black exited with status {}: {}",
                output.status,
                String::from_utf8_lossy(&output.stderr)
            ),
        ));
    }

    String::from_utf8(output.stdout).map_err(|e| {
        Error::new(
            ErrorKind::Other,
            format!("black produced invalid UTF-8: {e}"),
        )
    })
}

pub fn generate_py(
    ios_trait_schema: &TraitSchema,
    target_path: &Path,
    supertrait_schemas: &HashMap<String, TraitSchema>,
) -> Result<()> {
    let file_schema = trait_to_async_cffi_py_schema(ios_trait_schema, supertrait_schemas)
        .map_err(|m| Error::new(ErrorKind::Other, m))?;

    let new_contents = file_schema.codegen(0);
    let formatted_new_contents = format_python_code(&new_contents).unwrap_or_else(|err| {
        println!("cargo:warning=Failed to format generated Python code: {err}");
        new_contents
    });

    write_if_changed(&target_path, &formatted_new_contents)
}

fn write_if_changed(target_path: &Path, contents: &str) -> Result<()> {
    let normalized_new_contents = normalize_contents(contents);
    let should_write = fs::read_to_string(target_path)
        .map(|existing| normalize_contents(&existing) != normalized_new_contents)
        .unwrap_or(true);

    if should_write {
        if let Some(parent) = target_path.parent() {
            fs::create_dir_all(parent).map_err(|e| {
                Error::new(
                    ErrorKind::Other,
                    format!(
                        "failed to create parent directories for {}: {e}",
                        parent.display()
                    ),
                )
            })?;
        }

        let mut file = fs::File::create(target_path).map_err(|e| {
            Error::new(
                ErrorKind::Other,
                format!("failed to create file {}: {e}", target_path.display()),
            )
        })?;
        file.write_all(normalized_new_contents.as_bytes())
            .map_err(|e| {
                Error::new(
                    ErrorKind::Other,
                    format!("failed to write file {}: {e}", target_path.display()),
                )
            })?;
        file.sync_all().map_err(|e| {
            Error::new(
                ErrorKind::Other,
                format!("failed to sync file {}: {e}", target_path.display()),
            )
        })?;
    }

    Ok(())
}

fn normalize_contents(contents: &str) -> String {
    let mut normalized = contents.replace("\r\n", "\n");
    if !normalized.ends_with('\n') {
        normalized.push('\n');
    }

    normalized
}