alef 0.79.4

Opinionated polyglot binding generator for Rust libraries
Documentation
//! Auxiliary root-level generated files: per-package LICENSE copies and `.gitattributes`.

use crate::core::backend::GeneratedFile;
use crate::core::config::{Language, ResolvedCrateConfig};

/// Copy the workspace-root `LICENSE` file into each per-language package directory.
///
/// Reads `<workspace_root>/LICENSE` (falling back to `./LICENSE` when no workspace root is
/// configured). When the file is absent, this function warns and returns an empty list so
/// the caller can continue without error.
///
/// Emits one `GeneratedFile` per unique package directory that the languages list would
/// populate. Files with `generated_header: false` so they are create-once seeds —
/// `write_scaffold_files` skips them if they already exist, which keeps the copy
/// idempotent and `alef verify` happy (the file carries no `alef:hash:` marker).
///
/// Languages that do not produce a publishable package directory (Rust, C, FFI, JNI)
/// are skipped.
pub(crate) fn scaffold_license_files(config: &ResolvedCrateConfig, languages: &[Language]) -> Vec<GeneratedFile> {
    // Determine the path of the root LICENSE file.
    let license_path = config
        .workspace_root
        .as_deref()
        .map(|r| r.join("LICENSE"))
        .unwrap_or_else(|| std::path::PathBuf::from("LICENSE"));

    let license_content = match std::fs::read_to_string(&license_path) {
        Ok(content) => content,
        Err(_) => {
            tracing::warn!(
                "No LICENSE file found at {} — skipping LICENSE sync into package directories",
                license_path.display()
            );
            return vec![];
        }
    };

    let mut seen = std::collections::BTreeSet::new();
    let mut files = vec![];

    for &lang in languages {
        match lang {
            Language::Rust | Language::C | Language::Ffi | Language::Jni => continue,
            _ => {}
        }

        let pkg_dir = config.package_dir(lang);
        if seen.insert(pkg_dir.clone()) {
            files.push(GeneratedFile {
                // `Path::join` instead of `format!("{pkg_dir}/LICENSE")`: when `pkg_dir` already
                // ends in `/`, string concatenation produces a double-slash path
                // (`crates/<pkg>/src//LICENSE`) that no longer matches the on-disk path `alef
                // adopt` resolves against, permanently stranding the file as unadoptable. ~keep
                path: std::path::Path::new(&pkg_dir).join("LICENSE"),
                content: license_content.clone(),
                generated_header: false,
            });
        }
    }

    files
}

/// Emit a root-level `.gitattributes` that marks all generated output directories as
/// `linguist-generated=true`, causing GitHub to collapse them in PR diffs.
///
/// Covers three path categories:
/// - `packages/{lang}/` — language-native packages (Python, Ruby, PHP, Go, Java, …)
/// - `crates/{name}-{suffix}/` — Rust binding crates (pyo3, napi, php, ffi, jni)
/// - `e2e/` — cross-language test suites generated by `alef e2e generate`
///
/// The file uses `generated_header: false` (create-once seed). `write_scaffold_files`
/// skips it when `.gitattributes` already exists. Note: `alef scaffold --clean` passes
/// `overwrite=true` which DOES overwrite `generated_header: false` files — delete the
/// file beforehand if you want a fresh regeneration without `--clean`.
pub(crate) fn scaffold_gitattributes(config: &ResolvedCrateConfig, languages: &[Language]) -> Vec<GeneratedFile> {
    let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();

    for &lang in languages {
        match lang {
            Language::Rust | Language::C => {}
            Language::Ffi => {
                dirs.insert(format!("crates/{}-ffi", config.core_crate_dir()));
            }
            Language::Jni => {
                dirs.insert(format!("crates/{}-jni", config.name));
            }
            Language::Python => {
                dirs.insert(config.package_dir(lang));
                dirs.insert(format!("crates/{}-py", config.core_crate_dir()));
            }
            Language::Php => {
                dirs.insert(config.package_dir(lang));
                dirs.insert(format!("crates/{}-php", config.core_crate_dir()));
            }
            Language::Kotlin => {
                let dir = if let Some(k) = config.kotlin.as_ref() {
                    if k.mode.as_deref() == Some("kmp") || k.target == crate::core::config::KotlinTarget::Multiplatform
                    {
                        "packages/kotlin-mpp".to_string()
                    } else if k.target == crate::core::config::KotlinTarget::Native {
                        "packages/kotlin-native".to_string()
                    } else {
                        config.package_dir(lang)
                    }
                } else {
                    config.package_dir(lang)
                };
                dirs.insert(dir);
            }
            Language::Node => {
                let dir = config
                    .node
                    .as_ref()
                    .and_then(|c| c.crate_dir.as_ref())
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| format!("crates/{}-node", config.name));
                dirs.insert(dir);
            }
            _ => {
                dirs.insert(config.package_dir(lang));
            }
        }
    }

    let e2e_dir = config.e2e.as_ref().map(|e| e.output.as_str()).unwrap_or("e2e");
    dirs.insert(e2e_dir.to_string());

    let test_apps_dir = config
        .e2e
        .as_ref()
        .map(|e| e.registry.output.as_str())
        .unwrap_or("test_apps");
    dirs.insert(test_apps_dir.to_string());

    let mut content = String::from("# Generated by alef scaffold.\n");
    for dir in dirs {
        let dir = dir.trim_end_matches('/');
        content.push_str(&format!("{dir}/** linguist-generated=true\n"));
    }

    vec![GeneratedFile {
        path: std::path::PathBuf::from(".gitattributes"),
        content,
        generated_header: false,
    }]
}