alef 0.23.38

Opinionated polyglot binding generator for Rust libraries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
//! Package scaffolding generator for alef.

use crate::core::backend::GeneratedFile;
use crate::core::config::{Language, ResolvedCrateConfig, ScaffoldCargo, ScaffoldCargoEnvValue};
use crate::core::ir::ApiSurface;

mod languages;
pub(crate) mod naming;
mod template_env;

pub use languages::render_csharp_csproj;

/// Fields available via `[workspace.package]` inheritance detected from the root `Cargo.toml`.
#[derive(Debug, Default)]
pub(crate) struct WorkspacePackageInheritance {
    /// `version` is declared in `[workspace.package]`.
    pub version: bool,
    /// `readme` is declared in `[workspace.package]`.
    pub readme: bool,
    /// `keywords` is declared in `[workspace.package]`.
    pub keywords: bool,
    /// `categories` is declared in `[workspace.package]`.
    pub categories: bool,
    /// `license` is declared in `[workspace.package]`.
    pub license: bool,
}

/// Detect which `[workspace.package]` fields are available in the root `Cargo.toml`.
///
/// Reads `Cargo.toml` from the current working directory. Returns a default
/// (all false) struct if the file is absent or cannot be parsed.
pub(crate) fn detect_workspace_inheritance(workspace_root: Option<&std::path::Path>) -> WorkspacePackageInheritance {
    let cargo_toml_path = workspace_root
        .map(|r| r.join("Cargo.toml"))
        .unwrap_or_else(|| std::path::PathBuf::from("Cargo.toml"));
    let Ok(contents) = std::fs::read_to_string(&cargo_toml_path) else {
        return WorkspacePackageInheritance::default();
    };
    let Ok(doc) = contents.parse::<toml::Value>() else {
        return WorkspacePackageInheritance::default();
    };
    let Some(workspace) = doc.get("workspace") else {
        return WorkspacePackageInheritance::default();
    };
    let pkg = workspace.get("package");
    WorkspacePackageInheritance {
        version: pkg.map(|p| p.get("version").is_some()).unwrap_or(false),
        readme: pkg.map(|p| p.get("readme").is_some()).unwrap_or(false),
        keywords: pkg.map(|p| p.get("keywords").is_some()).unwrap_or(false),
        categories: pkg.map(|p| p.get("categories").is_some()).unwrap_or(false),
        license: pkg.map(|p| p.get("license").is_some()).unwrap_or(false),
    }
}

/// Build the `[package]` header fields for a binding crate Cargo.toml.
///
/// Uses `*.workspace = true` for any field that is available in `[workspace.package]`,
/// falling back to explicit values otherwise.
pub(crate) fn cargo_package_header(
    name: &str,
    version: &str,
    edition: &str,
    meta: &ScaffoldMeta,
    ws: &WorkspacePackageInheritance,
) -> String {
    let version_line = if ws.version {
        "version.workspace = true".to_string()
    } else {
        format!("version = \"{version}\"")
    };
    let edition_line = format!("edition = \"{edition}\"");
    let license_line = if ws.license {
        Some("license.workspace = true".to_string())
    } else {
        meta.license.as_ref().map(|license| format!("license = \"{license}\""))
    };
    let readme_line = if ws.readme {
        "readme.workspace = true".to_string()
    } else {
        "readme = false".to_string()
    };
    let keywords_line = if ws.keywords {
        "keywords.workspace = true".to_string()
    } else if meta.keywords.is_empty() {
        "keywords = []".to_string()
    } else {
        let quoted: Vec<String> = meta.keywords.iter().map(|k| format!("\"{k}\"")).collect();
        format!("keywords = [{}]", quoted.join(", "))
    };
    let categories_line = if ws.categories {
        "categories.workspace = true".to_string()
    } else if meta.categories.is_empty() {
        "categories = []".to_string()
    } else {
        let quoted: Vec<String> = meta.categories.iter().map(|k| format!("\"{k}\"")).collect();
        format!("categories = [{}]", quoted.join(", "))
    };

    let mut lines = vec![
        "[package]".to_string(),
        format!("name = \"{name}\""),
        version_line,
        edition_line,
        format!("description = \"{}\"", meta.description),
        readme_line,
        keywords_line,
        categories_line,
    ];
    if let Some(license_line) = license_line {
        lines.insert(4, license_line);
    }
    lines.join("\n")
}

/// e.g., "0.1.0-rc.1" -> "0.1.0rc1", "0.1.0-alpha.2" -> "0.1.0a2", "0.1.0-beta.3" -> "0.1.0b3"
/// Non-pre-release versions are returned unchanged.
pub(crate) fn to_pep440(version: &str) -> String {
    if let Some((base, pre)) = version.split_once('-') {
        let pep = pre
            .replace("alpha.", "a")
            .replace("alpha", "a")
            .replace("beta.", "b")
            .replace("beta", "b")
            .replace("rc.", "rc")
            .replace('.', "");
        format!("{base}{pep}")
    } else {
        version.to_string()
    }
}

/// Render a workspace-member core-facade dependency line in DUAL FORM.
///
/// Emits `crate_name = { version = "<version>", path = "<rel_path>"<features> }`.
/// The dual form keeps in-repo dev path builds working (the `path` is always
/// honored when the member crate is present on disk) while letting cargo's
/// package/publish flows (e.g. `maturin sdist`, `cargo package`) strip the
/// `path` and resolve the crate from the registry at `version`.
///
/// `features` is the already-formatted suffix as produced by
/// [`core_dep_features`] — either empty or `, features = ["a", "b"]`. It is
/// appended verbatim so callers control feature selection.
///
/// `version` is the resolved workspace version (the same value used for the
/// generated crate's `[package].version` and by version-sync). The `path` is
/// never altered, so dev builds against the local workspace continue to work.
/// When `version` is empty (no resolvable workspace version, e.g. some unit
/// fixtures), the line falls back to the path-only form so no invalid
/// `version = ""` is emitted.
pub(crate) fn render_core_dep(crate_name: &str, rel_path: &str, features: &str, version: &str) -> String {
    if version.is_empty() {
        format!("{crate_name} = {{ path = \"{rel_path}\"{features} }}")
    } else {
        format!("{crate_name} = {{ version = \"{version}\", path = \"{rel_path}\"{features} }}")
    }
}

///
/// Merges crate-level `extra_dependencies` with per-language overrides via
/// `extra_deps_for_language`, then serializes each entry as a TOML line suitable
/// for appending to a `[dependencies]` section.
///
/// Each value is either:
/// - A string (version only): `cratename = "1.0"`
/// - A TOML table (with path/features/etc.): `cratename = { path = "../foo", features = ["bar"] }`
///
/// Workspace members: when an entry is a path-only table (a `path` key, no
/// `version` key) whose crate name resolves to a workspace member, the resolved
/// workspace version is injected so the table becomes
/// `{ path = "../foo", version = "<v>" }` (dual form). This mirrors
/// [`render_core_dep`] for the core facade and lets cargo-package flows strip
/// the path to a registry version-dependency. `alef.toml` entries stay
/// path-only — the version is injected here at scaffold time. Non-member
/// external deps (e.g. `anyhow = "1.0"`) are emitted unchanged.
///
/// Returns an empty string if no extra dependencies are configured.
pub(crate) fn render_extra_deps(config: &ResolvedCrateConfig, lang: Language) -> String {
    let deps = config.extra_deps_for_language(lang);
    if deps.is_empty() {
        return String::new();
    }
    let member_versions = workspace_member_versions(config);
    let mut lines: Vec<String> = deps
        .iter()
        .map(|(name, value)| match value {
            toml::Value::String(version) => format!("{name} = \"{version}\""),
            toml::Value::Table(table) => {
                // Inject the resolved workspace version into path-only member
                // tables so cargo-package flows can resolve them from the
                // registry. Leave non-members and already-versioned tables as-is.
                let needs_version = table.contains_key("path") && !table.contains_key("version");
                if let (true, Some(member_version)) = (needs_version, member_versions.get(name)) {
                    let mut injected = table.clone();
                    injected.insert("version".to_string(), toml::Value::String(member_version.clone()));
                    format!("{name} = {}", toml::Value::Table(injected))
                } else {
                    format!("{name} = {value}")
                }
            }
            other => format!("{name} = {other}"),
        })
        .collect();
    // Sort for deterministic output.
    lines.sort();
    lines.join("\n")
}

/// Resolve the workspace-member crate name → version map for the crate's
/// workspace root.
///
/// Returns an empty map when no workspace root is configured or the root
/// `Cargo.toml` cannot be discovered/parsed — in that case no version is
/// injected and path-only deps are emitted unchanged (matching dev behavior
/// outside a resolvable workspace, e.g. unit tests).
fn workspace_member_versions(config: &ResolvedCrateConfig) -> std::collections::BTreeMap<String, String> {
    let Some(root) = config.workspace_root.as_deref() else {
        return std::collections::BTreeMap::new();
    };
    match crate::publish::workspace::workspace_member_crates(root) {
        Ok(members) => members.versions,
        Err(_) => std::collections::BTreeMap::new(),
    }
}

///
/// Checks for per-language feature overrides first, then falls back to `[crate] features`.
/// Returns an empty string if no features are configured, otherwise returns
/// `, features = ["feat1", "feat2"]`.
pub(crate) fn core_dep_features(config: &ResolvedCrateConfig, lang: Language) -> String {
    let features = config.features_for_language(lang);
    if features.is_empty() {
        String::new()
    } else {
        let quoted: Vec<String> = features.iter().map(|f| format!("\"{f}\"")).collect();
        format!(", features = [{}]", quoted.join(", "))
    }
}

pub fn scaffold(
    api: &ApiSurface,
    config: &ResolvedCrateConfig,
    languages: &[Language],
) -> anyhow::Result<Vec<GeneratedFile>> {
    let mut files = vec![];
    for &lang in languages {
        files.extend(scaffold_language(api, config, lang)?);
    }
    // Project-level files that depend on the full set of configured languages
    files.extend(scaffold_pre_commit_config(config, languages));

    // LICENSE sync — copy the workspace-root LICENSE into every per-language
    // package directory so ecosystems like pub.dev (Dart) that require a LICENSE
    // in the package root can publish successfully. Skips gracefully when no
    // LICENSE file is present at the workspace root.
    files.extend(scaffold_license_files(config, languages));

    // rust-toolchain.toml — pin Rust version, include wasm32 target when wasm is configured
    if !std::path::Path::new("rust-toolchain.toml").exists() {
        let targets = if languages.contains(&Language::Wasm) {
            "\ntargets = [\"wasm32-unknown-unknown\"]\n"
        } else {
            "\n"
        };
        files.push(GeneratedFile {
            path: std::path::PathBuf::from("rust-toolchain.toml"),
            content: format!(
                "[toolchain]\nchannel = \"1.95\"\ncomponents = [\"rust-src\", \"rustfmt\", \"clippy\"]\n{targets}"
            ),
            generated_header: false,
        });
    }

    // .cargo/config.toml
    //
    // Two modes, gated by `[scaffold.cargo]` in alef.toml:
    //
    //   * Opted in (`[scaffold.cargo]` present): alef writes the full canonical file
    //     with hash-based drift detection. Includes macOS dynamic_lookup rustflag
    //     (required for PyO3/ext-php-rs cdylibs to link on macOS), Windows MSVC
    //     rust-lld, aarch64-linux-gnu cross-gcc, x86_64-linux-musl, and the wasm32
    //     bulk-memory + getrandom_backend cfg. Per-target opt-out via
    //     `[scaffold.cargo.targets]`; repo-specific `[env]` via `[scaffold.cargo.env]`.
    //
    //   * Legacy (no `[scaffold.cargo]`): create-if-missing, wasm32-only block. No
    //     hash, no overwrite. Matches behavior prior to alef 0.13.6.
    if let Some(cargo) = config.scaffold.as_ref().and_then(|s| s.cargo.as_ref()) {
        files.push(GeneratedFile {
            path: std::path::PathBuf::from(".cargo/config.toml"),
            content: render_cargo_config(cargo),
            generated_header: true,
        });
    } else if languages.contains(&Language::Wasm) && !std::path::Path::new(".cargo/config.toml").exists() {
        files.push(GeneratedFile {
            path: std::path::PathBuf::from(".cargo/config.toml"),
            content: "[build]\nincremental = true\n\n[target.wasm32-unknown-unknown]\nrustflags = [\"-C\", \"target-feature=+bulk-memory\", \"--cfg\", \"getrandom_backend=\\\"wasm_js\\\"\"]\n\n[net]\ngit-fetch-with-cli = true\n\n[registries.crates-io]\nprotocol = \"sparse\"\n".to_string(),
            generated_header: false,
        });
    }

    // Typos configuration (spell checker)
    if !std::path::Path::new(".typos.toml").exists() {
        files.push(GeneratedFile {
            path: std::path::PathBuf::from(".typos.toml"),
            content: "[files]\nextend-exclude = [\"target/\", \".alef/\", \"*.lock\", \"*.min.js\"]\n\n[default.extend-words]\n# Add project-specific words here\n# crate_name = \"crate_name\"\n".to_string(),
            generated_header: false,
        });
    }

    // .gitattributes — mark all generated output directories as linguist-generated
    // so GitHub collapses them in PR diffs. create-once seed; skipped if the file
    // already exists so hand-added entries (e.g. `* text=auto`) are preserved.
    files.extend(scaffold_gitattributes(config, languages));

    Ok(files)
}

/// Render the canonical workspace `.cargo/config.toml` from a `[scaffold.cargo]`
/// configuration block.
///
/// The output is deterministic (same config → byte-identical output) and includes
/// the `auto-generated by alef` marker so `finalize_hashes` will stamp the
/// `alef:hash:` line during the scaffold pipeline.
///
/// Section order is fixed: header comment → `[build]` → `[net]` →
/// `[registries.crates-io]` → `[target.*]` blocks (in declaration order:
/// macOS dynamic_lookup, Windows MSVC x64+i686, aarch64-linux-gnu, x86_64-linux-musl,
/// wasm32) → optional `[env]`. `inject_hash_line` will insert the hash comment
/// directly after the marker line.
pub fn render_cargo_config(cargo: &ScaffoldCargo) -> String {
    let mut out = String::new();
    out.push_str("# This file is auto-generated by alef. DO NOT EDIT.\n");
    out.push_str("# Re-generate with: alef scaffold\n");
    out.push('\n');
    out.push_str("[build]\nincremental = true\n");
    if cargo.build_jobs > 0 {
        out.push_str(&format!("jobs = {}\n", cargo.build_jobs));
    }
    out.push('\n');
    out.push_str("[net]\ngit-fetch-with-cli = true\n\n");
    out.push_str("[registries.crates-io]\nprotocol = \"sparse\"\n");

    let t = &cargo.targets;
    if t.macos_dynamic_lookup {
        out.push_str(
            "\n# Required for PyO3 / ext-php-rs cdylibs: Python and Zend C-API symbols are\n\
             # resolved at runtime when the host loads the extension, not at link time.\n\
             # macOS ld is strict and rejects unresolved symbols by default.\n\
             [target.'cfg(target_os = \"macos\")']\n\
             rustflags = [\"-C\", \"link-arg=-Wl,-undefined,dynamic_lookup\"]\n",
        );
    }
    if t.x86_64_pc_windows_msvc {
        out.push_str("\n[target.x86_64-pc-windows-msvc]\nlinker = \"rust-lld\"\n");
    }
    if t.i686_pc_windows_msvc {
        out.push_str("\n[target.i686-pc-windows-msvc]\nlinker = \"rust-lld\"\n");
    }
    if t.aarch64_unknown_linux_gnu {
        out.push_str("\n[target.aarch64-unknown-linux-gnu]\nlinker = \"aarch64-linux-gnu-gcc\"\n");
    }
    if t.x86_64_unknown_linux_musl {
        out.push_str("\n[target.x86_64-unknown-linux-musl]\nlinker = \"musl-gcc\"\n");
    }
    if t.wasm32_unknown_unknown {
        out.push_str(
            "\n[target.wasm32-unknown-unknown]\n\
             rustflags = [\"-C\", \"target-feature=+bulk-memory\", \"--cfg\", \"getrandom_backend=\\\"wasm_js\\\"\"]\n",
        );
    }

    if !cargo.env.is_empty() {
        out.push_str("\n[env]\n");
        let mut keys: Vec<&String> = cargo.env.keys().collect();
        keys.sort();
        for key in keys {
            let value = &cargo.env[key];
            match value {
                ScaffoldCargoEnvValue::Plain(s) => {
                    out.push_str(&template_env::render(
                        "cargo_env_plain.jinja",
                        minijinja::context! { key => key, value => escape_toml_string(s) },
                    ));
                }
                ScaffoldCargoEnvValue::Structured { value, relative } => {
                    out.push_str(&template_env::render(
                        "cargo_env_structured.jinja",
                        minijinja::context! {
                            key => key,
                            value => escape_toml_string(value),
                            relative => relative,
                        },
                    ));
                }
            }
        }
    }

    out
}

/// Escape a string for TOML basic-string syntax: backslash + double-quote only.
/// (Tabs/newlines are preserved as-is — typical Cargo config values don't contain them.)
fn escape_toml_string(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

pub struct ScaffoldMeta {
    pub description: String,
    pub license: Option<String>,
    pub repository: Option<String>,
    pub configured_repository: Option<String>,
    pub homepage: String,
    pub documentation: String,
    pub issues: String,
    pub funding: String,
    pub authors: Vec<String>,
    pub keywords: Vec<String>,
    pub categories: Vec<String>,
}

pub fn scaffold_meta(config: &ResolvedCrateConfig) -> ScaffoldMeta {
    let scaffold = config.scaffold.as_ref();
    let package = config.package_metadata.as_ref();
    let truncate = package.map(|p| p.truncate_registry_lists).unwrap_or(false);
    let configured_repository = package
        .and_then(|p| p.repository.clone())
        .or_else(|| scaffold.and_then(|s| s.repository.clone()));
    let mut keywords = package
        .filter(|p| !p.keywords.is_empty())
        .map(|p| p.keywords.clone())
        .or_else(|| scaffold.map(|s| s.keywords.clone()))
        .unwrap_or_default();
    let mut categories = package.map(|p| p.categories.clone()).unwrap_or_default();
    keywords.sort();
    categories.sort();
    if truncate {
        keywords.truncate(5);
        categories.truncate(5);
    }
    ScaffoldMeta {
        description: package
            .and_then(|p| p.description.clone())
            .or_else(|| scaffold.and_then(|s| s.description.clone()))
            .unwrap_or_else(|| format!("Bindings for {}", config.name)),
        license: package
            .and_then(|p| p.license.clone())
            .or_else(|| scaffold.and_then(|s| s.license.clone())),
        repository: configured_repository.clone(),
        configured_repository,
        homepage: package
            .and_then(|p| p.homepage.clone())
            .or_else(|| scaffold.and_then(|s| s.homepage.clone()))
            .unwrap_or_default(),
        documentation: package.and_then(|p| p.documentation.clone()).unwrap_or_default(),
        issues: package.and_then(|p| p.issues.clone()).unwrap_or_default(),
        funding: package.and_then(|p| p.funding.clone()).unwrap_or_default(),
        authors: package
            .filter(|p| !p.authors.is_empty())
            .map(|p| p.authors.clone())
            .or_else(|| scaffold.map(|s| s.authors.clone()))
            .unwrap_or_default(),
        keywords,
        categories,
    }
}

/// Escape special characters for XML text content.
pub fn xml_escape(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

/// Parse an author string like `"Name <email>"` into `(name, email)`.
/// If no angle brackets are found, returns `(input, "")`.
pub fn parse_author(s: &str) -> (&str, &str) {
    if let Some(start) = s.find('<') {
        if let Some(end) = s.find('>') {
            let name = s[..start].trim();
            let email = &s[start + 1..end];
            return (name, email);
        }
    }
    (s.trim(), "")
}

pub(crate) fn capitalize_first(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        None => String::new(),
        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
    }
}

/// 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.
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![];
        }
    };

    // Collect unique package directories from publishable languages.
    // Languages without a real package output (Rust, C, FFI, JNI) are excluded.
    let mut seen = std::collections::BTreeSet::new();
    let mut files = vec![];

    for &lang in languages {
        match lang {
            // These languages do not produce a standalone publishable package directory.
            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: std::path::PathBuf::from(format!("{pkg_dir}/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`.
fn scaffold_gitattributes(config: &ResolvedCrateConfig, languages: &[Language]) -> Vec<GeneratedFile> {
    // Use BTreeSet so the output is stable and alphabetically sorted.
    let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();

    for &lang in languages {
        match lang {
            // Rust and C are source languages, not binding output directories.
            Language::Rust | Language::C => {}
            // FFI and JNI write only to their binding crates; package_dir() would return
            // a non-existent `packages/ffi` or `packages/jni` placeholder, so handle
            // them explicitly.
            Language::Ffi => {
                dirs.insert(format!("crates/{}-ffi", config.name));
            }
            Language::Jni => {
                dirs.insert(format!("crates/{}-jni", config.name));
            }
            // Python and PHP each have a language-native package directory AND a
            // separate Rust binding crate; include both.
            Language::Python => {
                dirs.insert(config.package_dir(lang));
                dirs.insert(format!("crates/{}-py", config.name));
            }
            Language::Php => {
                dirs.insert(config.package_dir(lang));
                dirs.insert(format!("crates/{}-php", config.name));
            }
            // Kotlin has three scaffold variants with distinct output directories:
            //   - JVM (default): packages/kotlin/
            //   - Native:        packages/kotlin-native/
            //   - Multiplatform: packages/kotlin-mpp/
            // package_dir() always returns packages/kotlin (JVM fallback), so we must
            // resolve the actual output directory from the configured target here.
            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);
            }
            // Node: package_dir() checks scaffold_output first, which is unrelated to where
            // scaffold_node and the napi backend actually write files. Those always use the
            // crate dir (crate_dir override or crates/{name}-node default). Bypass package_dir
            // to avoid emitting a wrong path when scaffold_output is set.
            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);
            }
            // Wasm: package_dir() uses only crate_dir/formula (no scaffold_output risk).
            _ => {
                dirs.insert(config.package_dir(lang));
            }
        }
    }

    // e2e output dir is configurable via [e2e] output = "..." (default "e2e").
    let e2e_dir = config.e2e.as_ref().map(|e| e.output.as_str()).unwrap_or("e2e");
    dirs.insert(e2e_dir.to_string());

    // test_apps output dir is configurable via [e2e.registry] output = "..."
    // (default "test_apps"). Registry-mode test_apps are emitted by
    // `alef test-apps generate`, mirroring the e2e local-mode flow.
    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,
    }]
}

use languages::{
    scaffold_csharp, scaffold_dart, scaffold_elixir, scaffold_elixir_cargo, scaffold_ffi, scaffold_gleam, scaffold_go,
    scaffold_java, scaffold_jni, scaffold_kotlin, scaffold_node, scaffold_node_cargo, scaffold_php, scaffold_php_cargo,
    scaffold_pre_commit_config, scaffold_python, scaffold_python_cargo, scaffold_r, scaffold_r_cargo, scaffold_ruby,
    scaffold_ruby_cargo, scaffold_swift, scaffold_wasm, scaffold_zig,
};

fn scaffold_language(
    api: &ApiSurface,
    config: &ResolvedCrateConfig,
    lang: Language,
) -> anyhow::Result<Vec<GeneratedFile>> {
    match lang {
        Language::Python => {
            let mut files = scaffold_python(api, config)?;
            files.extend(scaffold_python_cargo(api, config)?);
            Ok(files)
        }
        Language::Node => {
            let mut files = scaffold_node(api, config)?;
            files.extend(scaffold_node_cargo(api, config)?);
            Ok(files)
        }
        Language::Ffi => scaffold_ffi(api, config),
        Language::Go => scaffold_go(api, config),
        Language::Java => scaffold_java(api, config),
        Language::Csharp => scaffold_csharp(api, config),
        Language::Ruby => {
            let mut files = scaffold_ruby(api, config)?;
            files.extend(scaffold_ruby_cargo(api, config)?);
            Ok(files)
        }
        Language::Php => {
            let mut files = scaffold_php(api, config)?;
            files.extend(scaffold_php_cargo(api, config)?);
            Ok(files)
        }
        Language::Elixir => {
            let mut files = scaffold_elixir(api, config)?;
            files.extend(scaffold_elixir_cargo(api, config)?);
            Ok(files)
        }
        Language::Wasm => scaffold_wasm(api, config),
        Language::R => {
            let mut files = scaffold_r(api, config)?;
            files.extend(scaffold_r_cargo(api, config)?);
            Ok(files)
        }
        Language::Rust | Language::C => Ok(vec![]), // Rust/C don't need scaffolded binding crates
        Language::Jni => scaffold_jni(api, config),
        Language::Kotlin => scaffold_kotlin(api, config),
        // KotlinAndroid emission is fully handled by the dedicated backend
        // crate (`alef-backend-kotlin-android`); no scaffold step needed.
        Language::KotlinAndroid => Ok(vec![]),
        Language::Gleam => scaffold_gleam(api, config),
        Language::Zig => scaffold_zig(api, config),
        Language::Dart => scaffold_dart(api, config),
        Language::Swift => scaffold_swift(api, config),
    }
}

#[cfg(test)]
mod tests;