use super::normalization::normalize_content;
use super::write::apply_shebang_chmod;
use crate::core::backend::GeneratedFile;
use crate::core::config::{Language, ResolvedCrateConfig};
use crate::core::ir::ApiSurface;
use anyhow::Context as _;
use base64::Engine;
use std::path::Path;
use tracing::{debug, warn};
pub fn scaffold(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
config_path: &Path,
) -> anyhow::Result<Vec<GeneratedFile>> {
let mut files = crate::scaffold::scaffold(api, config, languages)?;
crate::with_extensions(|exts| {
let env = crate::core::template_env::TemplateEnv::new();
for ext in exts {
let raw = crate::core::extension::read_extension_config(config_path, ext.name())
.with_context(|| format!("extension `{}`: failed to read config from alef.toml", ext.name()))?;
let cfg = ext
.parse_config(raw.as_ref())
.with_context(|| format!("extension `{}`: failed to parse config", ext.name()))?;
for &language in languages {
ext.transform_scaffold_files(api, &cfg, language, &mut files, &env)
.with_context(|| {
format!(
"extension `{}`: transform_scaffold_files({language}) failed",
ext.name()
)
})?;
}
}
Ok::<(), anyhow::Error>(())
})?;
Ok(files)
}
pub fn readme(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
) -> anyhow::Result<Vec<GeneratedFile>> {
crate::readme::generate_readmes(api, config, languages)
}
pub fn write_scaffold_files(files: &[GeneratedFile], base_dir: &Path) -> anyhow::Result<usize> {
write_scaffold_files_with_overwrite(files, base_dir, false)
}
pub fn reconcile_managed_scaffold_manifests(
files: &[GeneratedFile],
base_dir: &Path,
) -> anyhow::Result<super::write::WriteReport> {
let mut manifests = Vec::new();
for file in files.iter().filter(|file| file.generated_header) {
let path = base_dir.join(&file.path);
if !path.exists() {
manifests.push(file.clone());
continue;
}
let content =
std::fs::read_to_string(&path).with_context(|| format!("failed to read existing {}", path.display()))?;
if crate::core::hash::content_has_alef_marker(&content) {
manifests.push(file.clone());
}
}
write_scaffold_files_report(&manifests, base_dir, false)
}
pub fn write_scaffold_files_with_overwrite(
files: &[GeneratedFile],
base_dir: &Path,
overwrite: bool,
) -> anyhow::Result<usize> {
Ok(write_scaffold_files_report(files, base_dir, overwrite)?.changed_count())
}
pub fn write_scaffold_files_report(
files: &[GeneratedFile],
base_dir: &Path,
overwrite: bool,
) -> anyhow::Result<super::write::WriteReport> {
let mut report = super::write::WriteReport::default();
let mut prepared = std::collections::BTreeMap::new();
for file in files {
if let Some(existing) = prepared.insert(file.path.clone(), file) {
anyhow::ensure!(
existing.content == file.content && existing.generated_header == file.generated_header,
"multiple generators emitted different content for {}",
file.path.display()
);
}
}
for file in prepared.into_values() {
let full_path = base_dir.join(&file.path);
let can_skip = !overwrite
&& !file.generated_header
&& full_path.exists()
&& !crate::cli::cache::is_alef_derived_output(&full_path);
if can_skip {
report.expected_paths.insert(full_path.clone());
debug!(" skipped (already exists): {}", full_path.display());
continue;
}
let is_jar_file = full_path.extension().is_some_and(|ext| ext == "jar");
let is_poly_merge_target = file.path == Path::new(POLY_CONFIG) && full_path.exists();
if is_jar_file {
let binary_content = base64::engine::general_purpose::STANDARD
.decode(&file.content)
.with_context(|| format!("failed to decode base64 for {}", full_path.display()))?;
let existing_binary = std::fs::read(&full_path).ok();
if existing_binary.as_deref() == Some(binary_content.as_slice()) {
debug!(" unchanged: {}", full_path.display());
continue;
}
if existing_binary.is_some() && !crate::cli::cache::is_scaffold_owned_path(base_dir, &full_path) {
warn!(
"refusing to write {}: pre-existing file has no durable record of alef \
ownership -- leaving it untouched",
full_path.display()
);
report.refused_paths.insert(full_path.clone());
continue;
}
report.expected_paths.insert(full_path.clone());
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
super::write::atomic_write(&full_path, &binary_content)?;
crate::cli::cache::record_scaffold_owned_path(base_dir, &full_path)?;
report.changed_paths.insert(full_path.clone());
debug!(" wrote (binary): {}", full_path.display());
continue;
}
let content = if is_poly_merge_target {
let existing = std::fs::read_to_string(&full_path)
.with_context(|| format!("failed to read existing {}", full_path.display()))?;
merge_managed_toml(&existing, &file.content, base_dir, &file.path)
.with_context(|| format!("failed to merge existing {}", full_path.display()))?
} else {
if file.path == Path::new(POLY_CONFIG) {
record_poly_merge_baseline(base_dir, &file.path, &file.content)
.with_context(|| format!("failed to record merge baseline for {}", full_path.display()))?;
}
file.content.clone()
};
let normalized = normalize_content(&full_path, &content);
let normalized = if file.generated_header {
super::write::ensure_generated_header(&full_path, &normalized)
} else {
normalized
};
if full_path.exists() {
let existing_text = std::fs::read_to_string(&full_path).ok();
let is_unchanged = existing_text.as_deref().is_some_and(|existing| {
crate::core::hash::strip_hash_line(existing) == crate::core::hash::strip_hash_line(&normalized)
});
if is_unchanged {
apply_shebang_chmod(&full_path, &normalized)?;
debug!(" unchanged: {}", full_path.display());
continue;
}
if !is_poly_merge_target {
let has_marker = existing_text
.as_deref()
.is_some_and(crate::core::hash::content_has_alef_marker);
let is_markable = super::write::marker_comment_style(&full_path).is_some();
let owned = has_marker
|| (!is_markable
&& (crate::cli::cache::is_scaffold_owned_path(base_dir, &full_path)
|| crate::e2e::snippets::is_snippet_coverage_manifest_path(&full_path)
|| crate::e2e::snippets::ownership::is_ledger_owned_snippet_path(base_dir, &full_path)));
if !owned {
match existing_text.as_deref().and_then(crate::core::hash::near_miss_marker) {
Some(near_miss) => warn!(
"refusing to write {}: pre-existing file carries no alef marker and \
alef has no durable record of ever owning it -- its leading lines \
contain something close to a marker ({near_miss:?}) that alef does \
not recognize; alef accepts \"generated by alef\" case-insensitively \
-- leaving it untouched",
full_path.display()
),
None => warn!(
"refusing to write {}: pre-existing file carries no alef marker and \
alef has no durable record of ever owning it -- leaving it untouched",
full_path.display()
),
}
report.refused_paths.insert(full_path.clone());
continue;
}
}
}
report.expected_paths.insert(full_path.clone());
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
super::write::atomic_write(&full_path, normalized.as_bytes())?;
apply_shebang_chmod(&full_path, &normalized)?;
if !is_poly_merge_target
&& super::write::marker_comment_style(&full_path).is_none()
&& !crate::e2e::snippets::ownership::is_ledger_owned_snippet_path(base_dir, &full_path)
{
crate::cli::cache::record_scaffold_owned_path(base_dir, &full_path)?;
}
report.changed_paths.insert(full_path.clone());
debug!(" wrote: {}", full_path.display());
if file.path == Path::new(POLY_CONFIG) {
normalize_poly_config(&full_path, base_dir);
}
}
if let Some(build_zig) = files
.iter()
.find(|file| file.path == Path::new("packages/zig/build.zig"))
{
crate::scaffold::migrate_build_zig_test_target(base_dir)
.context("failed to migrate pre-existing packages/zig/build.zig test target")?;
crate::scaffold::migrate_zig_build_ffi_include_default(base_dir, &build_zig.content)
.context("failed to migrate pre-existing packages/zig/build.zig ffi include default")?;
}
if let Some(dart_test_file) = files.iter().find(|file| {
file.path.starts_with("packages/dart/test")
&& file
.path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with("_test.dart"))
}) {
crate::scaffold::migrate_dart_placeholder_test(base_dir, &dart_test_file.path, &dart_test_file.content)
.context("failed to migrate pre-existing packages/dart/test/*_test.dart placeholder")?;
}
if let Some(swift_test_file) = files.iter().find(|file| {
file.path.starts_with("packages/swift/Tests")
&& file
.path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with("Tests.swift"))
}) {
crate::scaffold::migrate_swift_placeholder_test(base_dir, &swift_test_file.path, &swift_test_file.content)
.context("failed to migrate pre-existing packages/swift/Tests/*Tests.swift placeholder")?;
}
if let Some(pubignore_file) = files
.iter()
.find(|file| file.path == Path::new("packages/dart/.pubignore"))
{
crate::scaffold::migrate_dart_pubignore(base_dir, &pubignore_file.path, &pubignore_file.content)
.context("failed to migrate pre-existing packages/dart/.pubignore")?;
}
if let Some(wasm_pkg_file) = files.iter().find(|file| {
file.path
.to_str()
.is_some_and(|path| path.ends_with("-wasm/package.json"))
}) {
crate::scaffold::migrate_wasm_package_json_exports(base_dir, &wasm_pkg_file.path)
.context("failed to migrate pre-existing crates/*-wasm/package.json exports map")?;
}
if let Some(node_pkg_file) = files.iter().find(|file| {
file.path.file_name() == Some(std::ffi::OsStr::new("package.json"))
&& file
.path
.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with("-node"))
}) {
crate::scaffold::migrate_node_package_json_service_export(base_dir, &node_pkg_file.path)
.context("failed to migrate pre-existing crates/*-node/package.json service export")?;
}
if let Some(zig_example_file) = files
.iter()
.find(|file| file.path == Path::new("packages/zig/examples/example.zig"))
{
crate::scaffold::migrate_zig_example(base_dir, &zig_example_file.path, &zig_example_file.content)
.context("failed to migrate pre-existing packages/zig/examples/example.zig")?;
}
if files
.iter()
.any(|file| file.path == Path::new("packages/kotlin/build.gradle.kts"))
{
crate::scaffold::migrate_kotlin_build_gradle(base_dir)
.context("failed to migrate pre-existing packages/kotlin/build.gradle.kts")?;
}
for composer_file in files.iter().filter(|file| {
file.path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "composer.json")
}) {
crate::scaffold::migrate_php_composer_phpunit_constraint(base_dir, &composer_file.path)
.context("failed to migrate pre-existing composer.json phpunit constraint")?;
}
if files
.iter()
.any(|file| file.path == Path::new("packages/java/checkstyle.xml"))
{
crate::scaffold::migrate_java_checkstyle_line_length(base_dir, Path::new("packages/java/checkstyle.xml"))
.context("failed to migrate pre-existing packages/java/checkstyle.xml LineLength ceiling")?;
}
crate::scaffold::migrate_wasm_cargo_config_allow_multiple_definition(base_dir)
.context("failed to migrate pre-existing .cargo/config.toml wasm32 rustflags")?;
Ok(report)
}
const POLY_CONFIG: &str = "poly.toml";
pub(super) fn merge_managed_toml(
existing: &str,
generated: &str,
base_dir: &Path,
relative_path: &Path,
) -> anyhow::Result<String> {
let previous_generated_arrays = crate::cli::cache::read_toml_merge_provenance(base_dir, relative_path);
let (merged, current_generated_arrays) = merge_managed_toml_core(existing, generated, &previous_generated_arrays)?;
crate::cli::cache::write_toml_merge_provenance(base_dir, relative_path, ¤t_generated_arrays)?;
Ok(merged)
}
pub(super) fn merge_managed_toml_preview(
existing: &str,
generated: &str,
base_dir: &Path,
relative_path: &Path,
) -> anyhow::Result<String> {
let previous_generated_arrays = crate::cli::cache::read_toml_merge_provenance(base_dir, relative_path);
Ok(merge_managed_toml_core(existing, generated, &previous_generated_arrays)?.0)
}
fn record_poly_merge_baseline(base_dir: &Path, relative_path: &Path, generated: &str) -> anyhow::Result<()> {
let generated_doc = generated.parse::<toml_edit::DocumentMut>()?;
let mut arrays = std::collections::BTreeMap::new();
collect_arrays_by_path(generated_doc.as_table(), "", &mut arrays);
crate::cli::cache::write_toml_merge_provenance(base_dir, relative_path, &arrays)
}
fn merge_managed_toml_core(
existing: &str,
generated: &str,
previous_generated_arrays: &std::collections::BTreeMap<String, Vec<String>>,
) -> anyhow::Result<(String, std::collections::BTreeMap<String, Vec<String>>)> {
let mut existing_doc = existing.parse::<toml_edit::DocumentMut>()?;
let generated_doc = generated.parse::<toml_edit::DocumentMut>()?;
let mut current_generated_arrays = std::collections::BTreeMap::new();
collect_arrays_by_path(generated_doc.as_table(), "", &mut current_generated_arrays);
for (path, previous_values) in previous_generated_arrays {
let Some(current_values) = current_generated_arrays.get(path) else {
continue;
};
let dropped: Vec<String> = previous_values
.iter()
.filter(|value| !current_values.contains(value))
.cloned()
.collect();
if !dropped.is_empty() {
remove_values_at_path(existing_doc.as_table_mut(), path, &dropped);
}
}
merge_tables(existing_doc.as_table_mut(), generated_doc.as_table());
Ok((existing_doc.to_string(), current_generated_arrays))
}
fn merge_tables(existing: &mut toml_edit::Table, generated: &toml_edit::Table) {
for (key, generated_item) in generated {
match existing.get_mut(key) {
Some(existing_item) => merge_items(existing_item, generated_item),
None => {
existing.insert(key, detached_item(generated_item.clone()));
}
}
}
}
fn merge_items(existing: &mut toml_edit::Item, generated: &toml_edit::Item) {
match (existing, generated) {
(toml_edit::Item::Table(existing), toml_edit::Item::Table(generated)) => {
merge_tables(existing, generated);
}
(toml_edit::Item::Value(existing), toml_edit::Item::Value(generated)) => {
merge_values(existing, generated);
}
(existing, generated) => *existing = detached_item(generated.clone()),
}
}
fn merge_values(existing: &mut toml_edit::Value, generated: &toml_edit::Value) {
match (existing, generated) {
(toml_edit::Value::Array(existing), toml_edit::Value::Array(generated)) => {
for value in generated.iter() {
if !existing.iter().any(|candidate| values_equal(candidate, value)) {
existing.push(value.clone());
}
}
dedupe_array(existing);
}
(toml_edit::Value::InlineTable(existing), toml_edit::Value::InlineTable(generated)) => {
for (key, generated_value) in generated.iter() {
match existing.get_mut(key) {
Some(existing_value) => merge_values(existing_value, generated_value),
None => {
existing.insert(key, generated_value.clone());
}
}
}
}
(existing, generated) => {
let decor = existing.decor().clone();
*existing = generated.clone();
*existing.decor_mut() = decor;
}
}
}
fn strip_redundant_leading_slash(value: &str) -> &str {
let Some(rest) = value.strip_prefix('/') else {
return value;
};
let without_trailing_slash = rest.strip_suffix('/').unwrap_or(rest);
if without_trailing_slash.contains('/') {
rest
} else {
value
}
}
fn values_equal(existing: &toml_edit::Value, generated: &toml_edit::Value) -> bool {
match (existing, generated) {
(toml_edit::Value::String(existing), toml_edit::Value::String(generated)) => {
strip_redundant_leading_slash(existing.value()) == strip_redundant_leading_slash(generated.value())
}
(toml_edit::Value::Integer(existing), toml_edit::Value::Integer(generated)) => {
existing.value() == generated.value()
}
(toml_edit::Value::Float(existing), toml_edit::Value::Float(generated)) => {
existing.value() == generated.value()
}
(toml_edit::Value::Boolean(existing), toml_edit::Value::Boolean(generated)) => {
existing.value() == generated.value()
}
(toml_edit::Value::Datetime(existing), toml_edit::Value::Datetime(generated)) => {
existing.value() == generated.value()
}
(toml_edit::Value::Array(existing), toml_edit::Value::Array(generated)) => {
existing.len() == generated.len() && existing.iter().zip(generated.iter()).all(|(a, b)| values_equal(a, b))
}
(toml_edit::Value::InlineTable(existing), toml_edit::Value::InlineTable(generated)) => {
existing.len() == generated.len()
&& existing
.iter()
.all(|(key, value)| generated.get(key).is_some_and(|other| values_equal(value, other)))
}
(existing, generated) => existing.to_string().trim() == generated.to_string().trim(),
}
}
fn dedupe_array(array: &mut toml_edit::Array) {
let mut kept: Vec<toml_edit::Value> = Vec::new();
let mut index = 0;
while index < array.len() {
let is_duplicate = array
.get(index)
.is_some_and(|value| kept.iter().any(|seen| values_equal(seen, value)));
if is_duplicate {
array.remove(index);
continue;
}
if let Some(value) = array.get(index) {
kept.push(value.clone());
}
index += 1;
}
}
fn canonical_value_repr(value: &toml_edit::Value) -> String {
match value {
toml_edit::Value::String(value) => strip_redundant_leading_slash(value.value()).to_string(),
other => other.to_string().trim().to_string(),
}
}
fn collect_arrays_by_path(
table: &toml_edit::Table,
prefix: &str,
out: &mut std::collections::BTreeMap<String, Vec<String>>,
) {
for (key, item) in table {
let path = if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
};
match item {
toml_edit::Item::Table(nested) => collect_arrays_by_path(nested, &path, out),
toml_edit::Item::Value(toml_edit::Value::Array(array)) => {
out.insert(path, array.iter().map(canonical_value_repr).collect());
}
toml_edit::Item::Value(toml_edit::Value::InlineTable(inline)) => {
for (inner_key, inner_value) in inline.iter() {
if let toml_edit::Value::Array(array) = inner_value {
out.insert(
format!("{path}.{inner_key}"),
array.iter().map(canonical_value_repr).collect(),
);
}
}
}
_ => {}
}
}
}
fn remove_values_at_path(table: &mut toml_edit::Table, path: &str, values_to_remove: &[String]) {
let mut parts = path.splitn(2, '.');
let Some(head) = parts.next() else { return };
match (parts.next(), table.get_mut(head)) {
(None, Some(toml_edit::Item::Value(toml_edit::Value::Array(array)))) => {
remove_matching(array, values_to_remove);
}
(Some(rest), Some(toml_edit::Item::Table(nested))) => {
remove_values_at_path(nested, rest, values_to_remove);
}
(Some(rest), Some(toml_edit::Item::Value(toml_edit::Value::InlineTable(inline)))) => {
remove_values_at_inline_path(inline, rest, values_to_remove);
}
_ => {}
}
}
fn remove_values_at_inline_path(inline: &mut toml_edit::InlineTable, path: &str, values_to_remove: &[String]) {
let mut parts = path.splitn(2, '.');
let Some(head) = parts.next() else { return };
match (parts.next(), inline.get_mut(head)) {
(None, Some(toml_edit::Value::Array(array))) => remove_matching(array, values_to_remove),
(Some(rest), Some(toml_edit::Value::InlineTable(nested))) => {
remove_values_at_inline_path(nested, rest, values_to_remove);
}
_ => {}
}
}
fn remove_matching(array: &mut toml_edit::Array, values_to_remove: &[String]) {
let mut index = 0;
while index < array.len() {
let should_remove = array.get(index).is_some_and(|value| {
values_to_remove
.iter()
.any(|stale| *stale == canonical_value_repr(value))
});
if should_remove {
array.remove(index);
} else {
index += 1;
}
}
}
fn detached_item(mut item: toml_edit::Item) -> toml_edit::Item {
match &mut item {
toml_edit::Item::Value(value) => value.decor_mut().clear(),
toml_edit::Item::Table(table) => {
table.set_position(None);
let keys = table.iter().map(|(key, _)| key.to_string()).collect::<Vec<_>>();
for key in keys {
if let Some(child) = table.remove(&key) {
table.insert(&key, detached_item(child));
}
}
}
toml_edit::Item::ArrayOfTables(tables) => {
for table in tables.iter_mut() {
table.set_position(None);
}
}
toml_edit::Item::None => {}
}
item
}
fn normalize_poly_config(full_path: &Path, base_dir: &Path) {
crate::cli::pipeline::poly_format(std::slice::from_ref(&full_path.to_path_buf()), base_dir);
}
#[cfg(test)]
mod merge_managed_toml_tests {
use super::*;
fn exclude_values(merged: &str) -> Vec<String> {
let doc = merged
.parse::<toml_edit::DocumentMut>()
.expect("parse merged poly.toml");
doc["discovery"]["exclude"]
.as_array()
.expect("discovery.exclude is an array")
.iter()
.map(|value| value.as_str().expect("exclude entries are strings").to_string())
.collect()
}
#[test]
fn merge_does_not_duplicate_a_leading_slash_spelling_variant_of_an_existing_glob() {
let existing = "[discovery]\nexclude = [\"/packages/**\"]\n";
let generated = "[discovery]\nexclude = [\"packages/**\"]\n";
let previous_generated_arrays = std::collections::BTreeMap::new();
let (merged, _) =
merge_managed_toml_core(existing, generated, &previous_generated_arrays).expect("merge succeeds");
assert_eq!(
exclude_values(&merged),
vec!["/packages/**".to_string()],
"packages/** and /packages/** anchor identically to poly (the / before ** already \
anchors it) -- the union pass must not append a second, differently-spelled copy"
);
}
#[test]
fn merge_prunes_a_stale_glob_even_when_the_consumer_file_spells_it_with_a_leading_slash() {
let existing = "[discovery]\nexclude = [\"/packages/**\", \"kept/**\"]\n";
let generated = "[discovery]\nexclude = [\"kept/**\"]\n";
let mut previous_generated_arrays = std::collections::BTreeMap::new();
previous_generated_arrays.insert("discovery.exclude".to_string(), vec!["packages/**".to_string()]);
let (merged, current_generated_arrays) =
merge_managed_toml_core(existing, generated, &previous_generated_arrays).expect("merge succeeds");
assert_eq!(
exclude_values(&merged),
vec!["kept/**".to_string()],
"alef recorded packages/** as its own prior proposal and no longer generates it; the \
consumer's on-disk copy spelled it /packages/** and must still be pruned rather than \
surviving forever as unrecognised foreign content"
);
assert_eq!(
current_generated_arrays.get("discovery.exclude"),
Some(&vec!["kept/**".to_string()])
);
}
#[test]
fn strip_redundant_leading_slash_treats_a_dir_glob_leading_slash_as_a_no_op() {
assert_eq!(strip_redundant_leading_slash("/packages/**"), "packages/**");
assert_eq!(strip_redundant_leading_slash("packages/**"), "packages/**");
}
#[test]
fn strip_redundant_leading_slash_keeps_a_leading_slash_that_changes_anchoring() {
assert_eq!(strip_redundant_leading_slash("/Package.swift"), "/Package.swift");
assert_eq!(strip_redundant_leading_slash("Package.swift"), "Package.swift");
}
}