alef 0.77.0

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
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
//! Package scaffolding generator for alef.

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

mod generated_files;
mod languages;
pub(crate) mod naming;
mod repair;
mod template_env;
pub(crate) mod version_floor;

use generated_files::{scaffold_gitattributes, scaffold_license_files};
pub(crate) use repair::repair_missing_cfg_binding_features;

pub use languages::{
    PUBLISHED_RUNTIME_IDENTIFIERS, render_csharp_csproj, render_csharp_runtime_csproj,
    render_csharp_runtime_json_template,
};
pub(crate) use languages::{
    elixir_native_crate_dir, migrate_build_zig_test_target, migrate_dart_placeholder_test, migrate_dart_pubignore,
    migrate_java_checkstyle_line_length, migrate_kotlin_build_gradle, migrate_node_package_json_service_export,
    migrate_php_composer_phpunit_constraint, migrate_poly_toml_drop_snippet_hook, migrate_swift_placeholder_test,
    migrate_wasm_package_json_exports, migrate_zig_build_ffi_include_default, migrate_zig_example,
    ruby_native_manifest_path,
};

/// Fields available via `[workspace.package]` inheritance detected from the root `Cargo.toml`.
#[derive(Debug, Default, PartialEq, Eq)]
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();
    };
    // `toml` 1.x's `FromStr for Value` parses a bare *value*, not a document, so
    // `contents.parse::<toml::Value>()` fails at `[workspace]` on every real Cargo.toml
    // and silently yields an all-false result. `from_str` is the document entry point. ~keep
    let Ok(doc) = toml::from_str::<toml::Value>(&contents) 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),
    }
}

/// The `[workspace.package]` inheritance fields declared by the `Cargo.toml` at `dir`,
/// or `None` when the file is missing/unparseable, has no `[workspace]` table at all, or
/// has a `[workspace]` table with no `[workspace.package]` (an empty self-hosted
/// workspace root — a generated crate may declare a bare `[workspace]` to isolate itself
/// from an outer workspace's resolver without ever declaring its own inheritable fields;
/// that root still cannot satisfy `<field>.workspace = true`). ~keep
fn read_workspace_package_fields(dir: &std::path::Path) -> Option<WorkspacePackageInheritance> {
    let contents = std::fs::read_to_string(dir.join("Cargo.toml")).ok()?;
    let doc = toml::from_str::<toml::Value>(&contents).ok()?;
    let pkg = doc.get("workspace")?.get("package")?;
    Some(WorkspacePackageInheritance {
        version: pkg.get("version").is_some(),
        readme: pkg.get("readme").is_some(),
        keywords: pkg.get("keywords").is_some(),
        categories: pkg.get("categories").is_some(),
        license: pkg.get("license").is_some(),
    })
}

/// True when `crate_relative_dir` (forward-slash, relative to the workspace root) is
/// named in `root_doc`'s `[workspace] exclude` list — an exact match or a match against
/// an ancestor directory entry. This is not a full implementation of Cargo's
/// gitignore-style workspace-exclude glob syntax; it is sufficient for the literal
/// directory paths alef's own `exclude` generators, and every observed consumer
/// `Cargo.toml`, actually write to this list (no wildcards). ~keep
fn crate_dir_is_excluded(root_doc: &toml::Value, crate_relative_dir: &str) -> bool {
    let Some(excludes) = root_doc
        .get("workspace")
        .and_then(|w| w.get("exclude"))
        .and_then(|e| e.as_array())
    else {
        return false;
    };
    let normalized = crate_relative_dir.trim_matches('/');
    excludes.iter().filter_map(|entry| entry.as_str()).any(|pattern| {
        let pattern = pattern.trim_matches('/');
        normalized == pattern || normalized.starts_with(&format!("{pattern}/"))
    })
}

/// Detect which `[workspace.package]` fields a *specific* generated crate can actually
/// reach, unlike [`detect_workspace_inheritance`] (kept for callers that only ever emit
/// into a crate directory that is unconditionally a member of the root workspace).
///
/// A crate can inherit a field only if it can reach a `[workspace.package]` that defines
/// it:
/// - `crate_relative_dir` is a member of the workspace rooted at `workspace_root` — i.e.
///   not named in that root's `[workspace] exclude` — and that root declares the field
///   under `[workspace.package]`; or
/// - the crate's own pre-existing manifest at `<workspace_root>/<crate_relative_dir>/Cargo.toml`
///   self-hosts a `[workspace.package]` that defines the field (it declares its own
///   `[workspace]` table, making it its own workspace root).
///
/// Neither holding means every field is reported absent, so [`cargo_package_header`]
/// falls back to literals — the alternative, blindly trusting the root's
/// `[workspace.package]` regardless of exclusion, emits `<field>.workspace = true` into a
/// manifest that can never resolve it, which fails `cargo metadata` outright for any
/// crate excluded from the root workspace (Elixir NIF / Ruby native-extension crates are
/// excluded so their own toolchain, not the root workspace's resolver, builds them). ~keep
pub(crate) fn detect_workspace_inheritance_for_crate(
    workspace_root: Option<&std::path::Path>,
    crate_relative_dir: &str,
) -> WorkspacePackageInheritance {
    let Some(root) = workspace_root else {
        return WorkspacePackageInheritance::default();
    };
    let root_reaches = std::fs::read_to_string(root.join("Cargo.toml"))
        .ok()
        .and_then(|contents| toml::from_str::<toml::Value>(&contents).ok())
        .filter(|doc| doc.get("workspace").is_some())
        .is_some_and(|doc| !crate_dir_is_excluded(&doc, crate_relative_dir));
    if root_reaches && let Some(inheritance) = read_workspace_package_fields(root) {
        return inheritance;
    }
    read_workspace_package_fields(&root.join(crate_relative_dir)).unwrap_or_default()
}

/// 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} }}")
    }
}

/// Like [`render_core_dep`] but honours per-target overrides, mirroring the
/// FFI/Dart backends. Returns `(core_dep_line, target_blocks)`:
///
/// - with no overrides, `core_dep_line` is the single `[dependencies]` line and
///   `target_blocks` is empty (behaviour identical to [`render_core_dep`]);
/// - with overrides, `core_dep_line` is empty and `target_blocks` holds a
///   `[target.'cfg(not(any(<cfg…>)))'.dependencies]` default block plus one
///   `[target.'cfg(<cfg>)'.dependencies]` block per override.
///
/// `default_features` is the pre-formatted feature suffix (e.g. `, features =
/// ["a", "b"]` or `""`), matching [`render_core_dep`]. Callers place
/// `core_dep_line` inside `[dependencies]` when non-empty and append
/// `target_blocks` after that table.
pub(crate) fn render_core_dep_with_overrides(
    crate_name: &str,
    rel_path: &str,
    default_features: &str,
    version: &str,
    overrides: &[crate::core::config::FfiTargetDepOverride],
) -> (String, String) {
    if overrides.is_empty() {
        return (
            render_core_dep(crate_name, rel_path, default_features, version),
            String::new(),
        );
    }

    let combined_cfg = if overrides.len() == 1 {
        overrides[0].cfg.clone()
    } else {
        let cfgs: Vec<String> = overrides.iter().map(|o| o.cfg.clone()).collect();
        format!("any({})", cfgs.join(", "))
    };

    let mut entries: Vec<(String, String)> = vec![(
        format!("not({combined_cfg})"),
        render_core_dep(crate_name, rel_path, default_features, version),
    )];
    for override_ in overrides {
        let default_block = if override_.default_features {
            String::new()
        } else {
            ", default-features = false".to_string()
        };
        let feats = if override_.features.is_empty() {
            String::new()
        } else {
            let quoted: Vec<String> = override_.features.iter().map(|f| format!("\"{f}\"")).collect();
            format!(", features = [{}]", quoted.join(", "))
        };
        entries.push((
            override_.cfg.clone(),
            render_core_dep(crate_name, rel_path, &format!("{default_block}{feats}"), version),
        ));
    }
    (String::new(), join_sorted_target_dep_blocks(entries))
}

/// Assemble a sequence of `[target.'cfg(...)'.dependencies]` blocks in the
/// table order `cargo-sort` expects: alphabetically by the raw cfg predicate
/// string, using plain byte-wise (case-sensitive) comparison — the same
/// ordering `Vec<String>::sort()` / `str::cmp` produce.
///
/// `entries` is `(cfg_predicate, dependency_line)` pairs — one per target
/// block, including the default `not(...)` branch alongside every override.
/// Emitting the default branch unconditionally first (as earlier revisions of
/// this code did) is only coincidentally correct: `not(...)` sorts after
/// `all(...)` but before `target_os = "..."`, so a config with an `all(...)`
/// override (e.g. the macOS-Intel target) needs its block to precede the
/// default branch. Sorting all entries together — rather than hard-coding the
/// default first — is what keeps `cargo sort --check` passing regardless of
/// which cfg predicates a consumer configures.
///
/// Returns an empty string when `entries` is empty. Each block ends with a
/// trailing newline and blocks are separated by a single blank line, matching
/// the spacing callers already emit between `[dependencies]` and the first
/// target block.
pub(crate) fn join_sorted_target_dep_blocks(mut entries: Vec<(String, String)>) -> String {
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    entries
        .into_iter()
        .map(|(cfg, dep_line)| format!("[target.'cfg({cfg})'.dependencies]\n{dep_line}\n"))
        .collect::<Vec<_>>()
        .join("\n")
}

/// The sort key `cargo-sort` assigns to one rendered entry of a dependency table: the
/// dependency NAME alone, decoded, with any dotted suffix and any surrounding quotes removed.
///
/// cargo-sort sorts `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]` and their
/// `[target.'cfg(...)'.…]` counterparts with `toml_edit`'s `Table::sort_values`, which is
/// `IndexMap::sort_keys` over `Key: Ord`, and `Key::cmp` compares `Key::get()` -- the DECODED
/// text of a single key segment. A dotted entry such as `tracing.workspace = true` parses into
/// one key `tracing` holding a dotted sub-table, so the `.workspace` text is never part of the
/// comparison; a quoted key such as `"tree-sitter" = "1"` is compared unquoted; and a quoted key
/// that itself contains a dot is one segment, not two.
///
/// Sorting the rendered line text instead disagrees with that whenever one dependency name is a
/// prefix of another and the shorter one uses the dotted form: `-` is 0x2D and `.` is 0x2E, so
/// raw text puts `foo-bar = …` before `foo.workspace = true` where cargo-sort puts `foo` first.
/// That is the disagreement that failed `cargo sort --check --workspace` downstream. ~keep
pub(crate) fn dependency_sort_key(line: &str) -> String {
    let mut name = String::new();
    let mut quote: Option<char> = None;
    let mut escaped = false;
    for character in line.chars() {
        if escaped {
            name.push(character);
            escaped = false;
            continue;
        }
        match quote {
            Some('"') if character == '\\' => escaped = true,
            Some(open) if character == open => quote = None,
            Some(_) => name.push(character),
            None if character == '"' || character == '\'' => quote = Some(character),
            None if character == '.' || character == '=' => break,
            None => name.push(character),
        }
    }
    name.trim().to_owned()
}

/// Order rendered dependency-table lines the way `cargo sort --check` requires.
///
/// Stable, and keyed only on [`dependency_sort_key`], exactly matching the stable
/// `IndexMap::sort_keys` cargo-sort performs. Every emitter that writes a `[dependencies]`,
/// `[dev-dependencies]` or `[build-dependencies]` body from a list of rendered lines must sort
/// it through here rather than with `Vec::sort`. ~keep
pub(crate) fn sort_dependency_lines(lines: &mut [String]) {
    lines.sort_by_key(|a| dependency_sort_key(a));
}

///
/// 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 ws_dep_specs = workspace_dep_specs(config);
    let mut lines: Vec<String> = deps
        .iter()
        .map(|(name, value)| match value {
            toml::Value::String(version) => format!("{name} = \"{version}\""),
            toml::Value::Table(table) => {
                if table.get("workspace").and_then(|v| v.as_bool()) == Some(true) {
                    if let Some(concrete) = ws_dep_specs.get(name) {
                        return format!("{name} = {concrete}");
                    }
                    return format!("{name} = {value}");
                }
                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_dependency_lines(&mut lines);
    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(),
    }
}

/// Read the root `Cargo.toml`'s `[workspace.dependencies]` table and return the
/// concrete dependency specs keyed by crate name.
///
/// Used to resolve `{ workspace = true }` extra-dependency entries to concrete
/// specs so out-of-workspace binding crates (e.g. the R package at
/// `packages/r/src/rust/`) compile without a parent workspace. Returns an empty
/// map when no workspace root is configured, the root `Cargo.toml` is absent, or
/// the TOML cannot be parsed.
fn workspace_dep_specs(config: &ResolvedCrateConfig) -> std::collections::BTreeMap<String, toml::Value> {
    let start = config.workspace_root.clone().or_else(|| std::env::current_dir().ok());
    let Some(mut dir) = start else {
        return std::collections::BTreeMap::new();
    };

    if !dir.is_absolute()
        && let Ok(abs) = std::fs::canonicalize(&dir)
    {
        dir = abs;
    }

    loop {
        let cargo_path = dir.join("Cargo.toml");
        if let Ok(contents) = std::fs::read_to_string(&cargo_path)
            && let Ok(doc) = contents.parse::<toml_edit::DocumentMut>()
            && let Some(workspace) = doc.get("workspace")
            && let Some(dependencies) = workspace.get("dependencies")
            && let Some(table) = dependencies.as_table()
        {
            let mut result = std::collections::BTreeMap::new();
            for (key, value) in table.iter() {
                let val_str = value.to_string().trim().to_string();
                let wrapped = format!("x = {}", val_str);
                if let Ok(map) = toml::from_str::<std::collections::HashMap<String, toml::Value>>(&wrapped)
                    && let Some(v) = map.get("x")
                {
                    result.insert(key.to_string(), v.clone());
                }
            }
            return result;
        }
        if !dir.pop() {
            return std::collections::BTreeMap::new();
        }
    }
}

pub(crate) fn render_workspace_dep_or(config: &ResolvedCrateConfig, name: &str, fallback: &str) -> String {
    if workspace_dep_specs(config).contains_key(name) {
        format!("{name}.workspace = true")
    } else {
        format!("{name} = {fallback}")
    }
}

/// Rationale comment stamped immediately above every generated `[lints.clippy]` block
/// this module or its `scaffold::languages::*` callers emit, explaining why the crate
/// does not simply carry `[lints]\nworkspace = true` instead.
///
/// Baked into the generator rather than left for a consumer to hand-add, because these
/// binding-crate manifests (`crates/*-ffi`, `*-jni`, `*-node`, `*-php`, `*-py`,
/// `packages/r/**`, the Elixir NIF, the Ruby native extension) are `generated_header:
/// true` and rewritten in full whenever their content differs from what is on disk —
/// there is no comment-preserving merge for them the way [`merge_managed_toml`] exists
/// for `poly.toml`. A `~keep` marker only protects a comment from poly's own uncomment
/// pass; it does nothing against this full-file regeneration. The only comment that
/// reliably survives here is one alef itself emits on every run, which is what this
/// constant is for. `unsafe_code = "deny"` at `[workspace.lints.rust]` is the concrete
/// reason `[lints]\nworkspace = true` cannot be used: these crates cross a C-ABI / PyO3 /
/// napi / ext-php-rs / NIF boundary that requires `unsafe`, and that table is
/// all-or-nothing. ~keep
///
/// The emitted text carries its own `~keep` for the same reason any hand-authored
/// rationale in a consumer's tree does. Regeneration replaces this comment on every run,
/// but poly's uncomment pass runs *between* regenerations and strips any comment that is
/// not marked — so an unmarked rationale is deleted by the next `poly fmt`, and the
/// deletion lands in a commit that looks like unrelated formatting. Marking it also means
/// that where alef overwrites a consumer's own marked rationale here, what replaces it is
/// at least as durable as what it displaced. `strip_internal_doc_markers` does not reach
/// this text: it runs only inside `normalize_rustdoc`, on doc comments harvested from a
/// consumer's Rust source, never on scaffold-emitted TOML. ~keep
const CLIPPY_WORKSPACE_LINTS_RATIONALE: &str = "\
# This crate deliberately does not use `[lints]` / `workspace = true`: its C-ABI /\n\
# PyO3 / napi / ext-php-rs / NIF boundary requires `unsafe` code, and the workspace's\n\
# `[workspace.lints.rust]` sets `unsafe_code = \"deny\"` -- an all-or-nothing table that\n\
# would turn every such boundary into a compile error. The `[lints.clippy]` block below\n\
# instead carries the subset of the workspace's deny-by-default lint policy this crate\n\
# can actually satisfy. ~keep";

/// Insert [`CLIPPY_WORKSPACE_LINTS_RATIONALE`] immediately above the first
/// `[lints.clippy]` header in `rendered` (which may also carry a preceding
/// `[lints.rust]` table). A no-op if `rendered` carries no `[lints.clippy]` header at
/// all, which [`CargoLintsConfig::render`]/[`CargoLintsConfig::clippy_block`] never
/// actually produce (the builtin deny defaults guarantee one), but this function does
/// not assume that invariant on its caller's behalf.
fn with_clippy_rationale(rendered: &str) -> String {
    match rendered.find("[lints.clippy]") {
        Some(index) => {
            let (before, from_header) = rendered.split_at(index);
            format!("{before}{CLIPPY_WORKSPACE_LINTS_RATIONALE}\n{from_header}")
        }
        None => rendered.to_string(),
    }
}

/// Like [`CargoLintsConfig::clippy_block`] but with [`CLIPPY_WORKSPACE_LINTS_RATIONALE`]
/// spliced in immediately above the `[lints.clippy]` header, for callers (e.g. the
/// Elixir NIF template) that build their own `[lints.rust]` table by hand and only pull
/// the clippy table from [`CargoLintsConfig`] directly rather than going through
/// [`cargo_lints_section`].
pub(crate) fn cargo_lints_clippy_block_with_rationale(config: &ResolvedCrateConfig) -> String {
    with_clippy_rationale(&config.cargo_lints.clippy_block())
}

///
/// 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"]`.
/// Render `config.cargo_lints` for appending at the very END of a generated
/// Cargo.toml, after every dependency table.
///
/// `lints` is absent from cargo-sort's `DEF_TABLE_ORDER` (`package`, `workspace`,
/// `lib`, `bin`, `features`, `dependencies`, `build-dependencies`,
/// `dev-dependencies`), and cargo-sort appends every unlisted table after the
/// listed ones. Emitting `[lints.*]` between `[package]` and `[dependencies]`
/// therefore makes `cargo sort --check` reorder the manifest and fail it — under
/// the misleading message "Dependencies for <crate> are not sorted", even though
/// the dependency KEYS are already alphabetical. ~keep
///
/// The caller's template must already end with a newline; this returns
/// `\n{block}\n`, i.e. one blank separator line, the block, and the file's
/// trailing newline. Returns an empty string when no lints are configured, so
/// `...last-line\n{lints_section}"#` stays correct either way.
pub(crate) fn cargo_lints_section(config: &ResolvedCrateConfig) -> String {
    let rendered = with_clippy_rationale(&config.cargo_lints.render());
    if rendered.is_empty() {
        String::new()
    } else {
        format!("\n{rendered}\n")
    }
}

pub(crate) fn core_dep_features(config: &ResolvedCrateConfig, lang: Language) -> String {
    core_dep_features_excluding(config, lang, &std::collections::HashSet::new())
}

/// Like [`core_dep_features`], but drops any name in `excluded` from the core dependency's
/// `features = [...]` line.
///
/// Mirrors `scaffold::languages::ruby::ruby_core_dep_features`, generalized here because more
/// than two languages need it -- see `RubyConfig::excluded_default_features`'s doc comment for
/// the full rationale. The consumer-facing exclusion is meant to keep a feature off this
/// dependency edge entirely, not just out of the wrapper's own `default = [...]` array:
/// forwarding an excluded name into this explicit line unions it straight back into the core
/// crate via Cargo's feature unification, defeating a `target_dep_overrides` entry that tried to
/// turn it off for a specific cfg target. Missing this surface -- filtering only the wrapper's
/// own default array -- is precisely what left the original Swift widening defect reachable. ~keep
pub(crate) fn core_dep_features_excluding(
    config: &ResolvedCrateConfig,
    lang: Language,
    excluded: &std::collections::HashSet<&str>,
) -> String {
    let features: Vec<&str> = config
        .features_for_language(lang)
        .iter()
        .map(String::as_str)
        .filter(|f| !excluded.contains(f))
        .collect();
    if features.is_empty() {
        String::new()
    } else {
        let quoted: Vec<String> = features.iter().map(|f| format!("\"{f}\"")).collect();
        format!(", features = [{}]", quoted.join(", "))
    }
}

/// Locate the core crate's `Cargo.toml` for a resolved config.
///
/// Derives the crate directory from the first source path (walking up to the
/// `src/` parent, mirroring [`ResolvedCrateConfig::core_crate_dir`]) and joins
/// it against `workspace_root`. Returns `None` when there is no workspace root
/// (the binding is being scaffolded standalone) or when the path cannot be
/// derived — both cases simply skip the `android-target` aggregate emission.
pub(crate) fn core_crate_manifest_path(config: &ResolvedCrateConfig) -> Option<std::path::PathBuf> {
    let workspace_root = config.workspace_root.as_deref()?;
    let first_source = config.sources.first()?;
    let mut current = std::path::Path::new(first_source).parent();
    while let Some(dir) = current {
        if dir.file_name().is_some_and(|n| n == "src") {
            let crate_dir = dir.parent()?;
            return Some(workspace_root.join(crate_dir).join("Cargo.toml"));
        }
        current = dir.parent();
    }
    None
}

/// Resolve a core-crate aggregate feature to the transitive set of feature-name
/// tokens reachable from it.
///
/// BFS over the core crate's `[features]` map starting at `aggregate`. Every
/// member token that is itself a key in the map is followed; tokens of the
/// `dep:foo` or `crate/feat` form are skipped (they are not binding-side
/// passthrough features). The returned set therefore contains every plain
/// feature name reachable from the aggregate, including the aggregate's own
/// sub-aggregates' leaves. Returns `None` when the core crate has no feature by
/// that name (so callers can skip emission for repos that lack it).
fn resolve_core_aggregate_features(
    features_table: &toml::value::Table,
    aggregate: &str,
) -> Option<std::collections::BTreeSet<String>> {
    let _ = features_table.get(aggregate)?;
    let mut reachable: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
    let mut queue: Vec<String> = vec![aggregate.to_string()];
    let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
    while let Some(name) = queue.pop() {
        if !visited.insert(name.clone()) {
            continue;
        }
        let Some(members) = features_table.get(&name).and_then(|v| v.as_array()) else {
            continue;
        };
        for member in members {
            let Some(token) = member.as_str() else { continue };
            if token.starts_with("dep:") || token.contains('/') {
                continue;
            }
            reachable.insert(token.to_string());
            if features_table.contains_key(token) {
                queue.push(token.to_string());
            }
        }
    }
    Some(reachable)
}

/// Read the core crate's own `[features]` table and resolve `requested` (the literal feature
/// names on the core dependency line, e.g. `["native-http", "full"]`) into the full transitive
/// set of features that ends up active, plus the core crate's own declared `default` feature
/// list. Each name in `requested` that is itself an aggregate in the core crate's `[features]`
/// table (e.g. `full`) is expanded via [`resolve_core_aggregate_features`]; plain leaf names are
/// kept as-is. Falls back to `requested` verbatim as the active set (and an empty default list)
/// when the core manifest can't be located, read, or parsed — the same permissive fallback
/// [`android_target_feature_line_for_dep`] uses.
pub(crate) fn core_feature_closure(
    config: &ResolvedCrateConfig,
    requested: &[String],
) -> (std::collections::BTreeSet<String>, std::collections::BTreeSet<String>) {
    let mut active: std::collections::BTreeSet<String> = requested.iter().cloned().collect();
    let no_defaults = std::collections::BTreeSet::new();
    let Some(manifest_path) = core_crate_manifest_path(config) else {
        return (active, no_defaults);
    };
    let Ok(contents) = std::fs::read_to_string(&manifest_path) else {
        return (active, no_defaults);
    };
    let Ok(doc) = toml::from_str::<toml::Value>(&contents) else {
        return (active, no_defaults);
    };
    let Some(features_table) = doc.get("features").and_then(|v| v.as_table()) else {
        return (active, no_defaults);
    };
    for name in requested {
        if let Some(members) = resolve_core_aggregate_features(features_table, name) {
            active.extend(members);
        }
    }
    let defaults: std::collections::BTreeSet<String> = features_table
        .get("default")
        .and_then(|v| v.as_array())
        .map(|arr| arr.iter().filter_map(|v| v.as_str()).map(String::from).collect())
        .unwrap_or_default();
    (active, defaults)
}

/// Compute the binding-crate `android-target` aggregate feature line, if applicable.
///
/// The consuming repo's core crate may define an `android-target` aggregate (a
/// curated ORT-free, libheif-free feature set) so it can be cross-compiled for
/// Android via `cargo ndk ... --no-default-features --features android-target`.
/// The binding crate's own FFI exports are gated by its passthrough features, so
/// it must expose a matching `android-target` that enables the passthrough
/// features that are members of the core aggregate — not merely forward to the
/// core dep.
///
/// `passthrough_feature_names` is the binding crate's own forwarding feature set
/// (the names that appear in its `[features]` passthrough block, e.g. `pdf`,
/// `ocr`), excluding the `full` umbrella. Returns the emitted line:
///
/// ```text
/// android-target = ["<core>/android-target", <sorted passthrough ∩ core aggregate>]
/// ```
///
/// Returns `None` when the core crate has no `android-target` feature (so other
/// consuming repos are unaffected) or when its manifest cannot be read.
pub(crate) fn android_target_feature_line(
    config: &ResolvedCrateConfig,
    passthrough_feature_names: &[&str],
) -> Option<String> {
    android_target_feature_line_for_dep(config, &config.name, passthrough_feature_names)
}

/// Variant of [`android_target_feature_line`] that takes the core-crate cargo
/// dep key explicitly.
///
/// The FFI crate forwards via the cargo package name (`config.name`), whereas
/// the dart bridge crate forwards via the rust-ident dep key (e.g. `sample_lib`)
/// to match its other passthrough entries. Both share the same resolution logic.
pub(crate) fn android_target_feature_line_for_dep(
    config: &ResolvedCrateConfig,
    core_dep_key: &str,
    passthrough_feature_names: &[&str],
) -> Option<String> {
    let manifest_path = core_crate_manifest_path(config)?;
    let contents = std::fs::read_to_string(&manifest_path).ok()?;
    let doc = toml::from_str::<toml::Value>(&contents).ok()?;
    let features_table = doc.get("features")?.as_table()?;
    let aggregate_members = resolve_core_aggregate_features(features_table, "android-target")?;

    let mut selected: Vec<&str> = passthrough_feature_names
        .iter()
        .copied()
        .filter(|name| *name != "full" && aggregate_members.contains(*name))
        .collect();
    selected.sort_unstable();
    selected.dedup();

    let mut tokens: Vec<String> = vec![format!("{core_dep_key}/android-target")];
    tokens.extend(selected.iter().map(|name| (*name).to_string()));
    let list = tokens.iter().map(|t| format!("\"{t}\"")).collect::<Vec<_>>().join(", ");
    Some(format!("android-target = [{list}]"))
}

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)?);
    }
    // Every binding manifest above is `generated_header: true` and therefore rewritten in
    // full, so a dependency literal that has fallen behind the consumer's own tree is
    // written back over their bump on every run. Raise each requirement to the committed
    // one before anything else sees these files, so the whole pipeline -- write, `diff`,
    // `verify` -- agrees that a version alef would have lowered is not a difference. ~keep
    version_floor::apply_version_floors(&mut files, config);
    files.extend(scaffold_poly_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
    // LICENSE file is present at the workspace root.
    files.extend(scaffold_license_files(config, languages));

    if !std::path::Path::new("rust-toolchain.toml").exists() {
        files.push(rust_toolchain_file(languages));
    }

    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(wasm_cargo_config_file());
    }

    files.extend(scaffold_gitattributes(config, languages));

    Ok(files)
}

/// The wasm-only `.cargo/config.toml` seed, emitted when no `[scaffold.cargo]` is configured.
///
/// Split out of [`scaffold`] so it is reachable from a test: the emit site is gated on
/// `Path::new(".cargo/config.toml").exists()` against the **process CWD**, not the write
/// `base_dir`, so a test that goes through `scaffold` observes whatever the repo running the test
/// happens to have at its root — which is how a marker regression on this file stays invisible.
/// See [`rust_toolchain_file`] for the same reasoning on the other create-once seed. ~keep
fn wasm_cargo_config_file() -> GeneratedFile {
    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\\\"\", \"-C\", \"link-arg=--allow-multiple-definition\"]\n\n[net]\ngit-fetch-with-cli = true\n\n[registries.crates-io]\nprotocol = \"sparse\"\n".to_string(),
        // The `[scaffold.cargo]` branch above writes the *same path* through
        // `render_cargo_config`, which hand-rolls its own "auto-generated by alef" header and so
        // is claimed, stamped and skipped by poly. This branch emitted the same path unmarked, so
        // which of the two a repo got decided whether `.cargo/config.toml` carried an
        // `alef:hash:` line at all — and an unmarked-but-alef-authored file is exactly the state
        // poly reformats and `alef verify` cannot see. Both branches now land on the marker rail.
        // Safe despite the ownership guard's create-once trap because the emit is already gated
        // on the file not existing, so the write is always a create. ~keep
        generated_header: true,
    }
}

/// The `rust-toolchain.toml` seed. Split out of [`scaffold`] for the same CWD reason as
/// [`wasm_cargo_config_file`] — alef's own repo root carries a `rust-toolchain.toml`, so every
/// test that reaches this through `scaffold` sees the file suppressed and asserts nothing. ~keep
fn rust_toolchain_file(languages: &[Language]) -> GeneratedFile {
    let targets = if languages.contains(&Language::Wasm) {
        "targets = [\"wasm32-unknown-unknown\"]\n"
    } else {
        ""
    };
    GeneratedFile {
        path: std::path::PathBuf::from("rust-toolchain.toml"),
        content: format!(
            "[toolchain]\nchannel = \"1.95\"\ncomponents = [\"rust-src\", \"rustfmt\", \"clippy\"]\n{targets}"
        ),
        // Was `false`, which meant `ensure_generated_header` never ran and the file reached disk
        // with no marker: alef wrote every byte of it, `finalize_hashes` skipped it for want of a
        // marker to inject after, and poly's hash-keyed skip therefore reformatted it on every
        // run. Gated on non-existence, so flipping this can only affect a create. ~keep
        generated_header: true,
    }
}

/// The exact `.cargo/config.toml` body [`scaffold`] emitted for wasm-only projects (no
/// `[scaffold.cargo]` configured) before the fix that added
/// `-C link-arg=--allow-multiple-definition` to the wasm32 rustflags.
const STALE_WASM_CARGO_CONFIG: &str = "[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";

/// Repair a pre-existing `.cargo/config.toml` that still carries the pre-fix wasm32 rustflags
/// -- the exact defect fixed when this file's hardcoded, wasm-only literal above gained
/// `-C link-arg=--allow-multiple-definition` (`cda088792`, "allow multiple definition on
/// wasm32 link"). wasm32-unknown-unknown has no unified libc, so multiple C dependencies
/// (tree-sitter's wasm shim, a WASI-built Tesseract) can each ship functionally-equivalent
/// libc stubs that `wasm-ld` rejects as duplicate definitions without this flag; a repo that
/// never happens to combine such dependencies never hits the failure, which is why this can
/// stay unnoticed indefinitely once scaffolded.
///
/// This file is unusual among the create-once scaffold seeds above: `scaffold()`'s `else if`
/// arm only ever pushes it into the returned `files` list when `.cargo/config.toml` does
/// *not already exist* on disk, so once a repo has one it drops out of `files` entirely and
/// never reaches `write_scaffold_files_report`'s per-file ownership guard the way the other
/// create-once seeds (the zig/dart/swift placeholders, `.pubignore`, `example.zig`) do.
/// Detection here is therefore unconditional on the generated file list and purely
/// content-driven: an exact byte match against the one known-bad constant. This file carries
/// no per-project variables at all (this branch only fires without `[scaffold.cargo]`
/// configured, so nothing here is templated), so exact-match is both sufficient and maximally
/// conservative -- any consumer edit at all leaves the file completely untouched. ~keep
pub(crate) fn migrate_wasm_cargo_config_allow_multiple_definition(base_dir: &std::path::Path) -> anyhow::Result<bool> {
    let path = base_dir.join(".cargo/config.toml");
    let Ok(existing) = std::fs::read_to_string(&path) else {
        return Ok(false);
    };
    if existing != STALE_WASM_CARGO_CONFIG {
        return Ok(false);
    }

    // Taken from the emitter rather than repeated as a fourth copy of the same literal: a repair
    // that writes bytes the emitter no longer produces converges the file onto a body no
    // subsequent run agrees with. The header is deliberately absent — `write_scaffold_files_report`
    // adds it, and this path writes directly, so a header written here would be claimed by
    // `content_has_alef_marker` and never stamped, which is the poly ping-pong state. ~keep
    let replacement = wasm_cargo_config_file().content;

    let parent = path
        .parent()
        .context(".cargo/config.toml path has no parent directory")?;
    let mut temporary = tempfile::NamedTempFile::new_in(parent)
        .with_context(|| format!("failed to create temporary file in {}", parent.display()))?;
    std::io::Write::write_all(&mut temporary, replacement.as_bytes())
        .with_context(|| format!("failed to write temporary file for {}", path.display()))?;
    temporary
        .persist(&path)
        .map_err(|error| error.error)
        .with_context(|| format!("failed to replace {}", path.display()))?;
    // Fires only after the replace above already succeeded: a completed self-heal, not an
    // outstanding problem. ~keep
    tracing::info!(
        path = %path.display(),
        "repaired pre-existing .cargo/config.toml: added -C link-arg=--allow-multiple-definition \
         to the wasm32-unknown-unknown rustflags"
    );
    Ok(true)
}

/// 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));
    }
    if let Some(wrapper) = cargo.rustc_wrapper.as_deref() {
        out.push_str(&format!("rustc-wrapper = \"{}\"\n", escape_toml_string(wrapper)));
    }
    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\\\"\", \"-C\", \"link-arg=--allow-multiple-definition\"]\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 => if *relative { "true" } else { "false" },
                        },
                    ));
                }
            }
        }
    }

    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,
    }
}

/// Returns true when `crates.readme.languages.<lang_code>` is configured for this
/// crate, meaning the README module in [`crate::readme`] owns `packages/<lang>/README.md`
/// end-to-end (badges, "What This Package Provides", Quick Start, feature/OCR
/// sections, snippets).
///
/// A handful of scaffold language modules (currently Swift, Dart, Zig) also emit a
/// minimal placeholder `README.md` alongside their package skeleton, predating the
/// languages having any `[crates.readme.languages.*]` entry at all. That placeholder
/// is a second, independent writer for the exact output path the README module
/// targets: `alef all --clean` always has the README stage overwrite it afterwards,
/// but any run that only performs scaffolding (`alef scaffold`, a `--lang`-scoped
/// scaffold-only pass, or a run that errors out before reaching the README stage)
/// leaves the placeholder as the final, committed content — silently discarding
/// every section the crate's `alef.toml` configured, with no error and no diff
/// signal (#555). Scaffold modules must call this and skip emitting their own
/// `README.md` once the language has real README config, so there is only ever one
/// writer for that path and the file is either the fully rendered template or (for
/// an as-yet-unconfigured language) the historical placeholder — never a silent mix
/// of the two depending on which command happened to run last. ~keep
pub(crate) fn readme_language_configured(config: &ResolvedCrateConfig, lang_code: &str) -> bool {
    config
        .readme
        .as_ref()
        .is_some_and(|readme| readme.languages.contains_key(lang_code))
}

/// 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('<')
        && 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.
///
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_poly_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![]),
        Language::Jni => scaffold_jni(api, config),
        Language::Kotlin => scaffold_kotlin(api, config),
        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;