pub mod codegen;
pub mod config;
mod coverage_cache;
pub mod escape;
pub mod field_access;
pub mod fixture;
pub mod format;
mod inert_report;
pub mod scaffold;
pub mod snippets;
pub mod template_env;
pub mod validate;
pub mod validate_call_args;
pub mod validate_call_class;
pub mod validate_call_module;
pub mod validate_call_result_type;
use crate::core::backend::GeneratedFile;
use crate::core::config::e2e::DependencyMode;
use crate::core::config::{Language, ResolvedCrateConfig};
use anyhow::{Context, Result, bail};
use config::E2eConfig;
use fixture::{group_fixtures, load_fixtures};
use std::path::Path;
use tracing::{error, info, warn};
use validate::Severity;
pub fn default_e2e_languages(scaffolded: &[Language]) -> Vec<String> {
let mut names: Vec<String> = scaffolded
.iter()
.map(|l| match l {
Language::Ffi => "c".to_string(),
other => other.to_string(),
})
.collect();
if !names.iter().any(|n| n == "rust") {
names.push("rust".to_string());
}
names
}
pub fn known_e2e_target_names() -> Vec<String> {
let mut names: Vec<String> = codegen::all_generators()
.iter()
.map(|generator| generator.language_name().to_string())
.collect();
names.sort();
names.dedup();
names
}
pub fn generate_e2e(
config: &ResolvedCrateConfig,
e2e_config: &E2eConfig,
languages: Option<&[String]>,
type_defs: &[crate::core::ir::TypeDef],
enums: &[crate::core::ir::EnumDef],
functions: &[crate::core::ir::FunctionDef],
errors: &[crate::core::ir::ErrorDef],
) -> Result<(Vec<GeneratedFile>, Option<anyhow::Error>)> {
crate::with_extensions(|extensions| {
generate_e2e_with_extensions(
config, e2e_config, languages, type_defs, enums, functions, errors, extensions,
)
})
}
#[allow(clippy::too_many_arguments)]
fn generate_e2e_with_extensions(
config: &ResolvedCrateConfig,
e2e_config: &E2eConfig,
languages: Option<&[String]>,
type_defs: &[crate::core::ir::TypeDef],
enums: &[crate::core::ir::EnumDef],
functions: &[crate::core::ir::FunctionDef],
errors: &[crate::core::ir::ErrorDef],
extensions: &[Box<dyn crate::Extension>],
) -> Result<(Vec<GeneratedFile>, Option<anyhow::Error>)> {
let fixtures_dir = Path::new(&e2e_config.fixtures);
let fixtures = load_fixtures(fixtures_dir)
.with_context(|| format!("failed to load fixtures from {}", fixtures_dir.display()))?;
info!("Loaded {} fixture(s) from {}", fixtures.len(), e2e_config.fixtures);
let docs_only_fixtures = fixture::docs_only::load_docs_only_fixtures(fixtures_dir)
.with_context(|| format!("failed to load docs-only fixtures from {}", fixtures_dir.display()))?;
for docs_only_fixture in &docs_only_fixtures {
fixture::docs_only::validate_api_references(docs_only_fixture, type_defs, enums, errors, functions)
.context("docs-only fixture validation failed")?;
}
if !docs_only_fixtures.is_empty() {
info!(
"Loaded {} docs-only fixture(s) from {}",
docs_only_fixtures.len(),
e2e_config.fixtures
);
}
let configured_languages: Vec<String> = if !e2e_config.languages.is_empty() {
e2e_config.languages.clone()
} else {
default_e2e_languages(&config.languages)
};
fixture::validate_skip_languages(&fixtures, &configured_languages)?;
let resolved_languages: Vec<String> = if let Some(langs) = languages {
langs.to_vec()
} else if !e2e_config.languages.is_empty() {
e2e_config.languages.clone()
} else {
default_e2e_languages(&config.languages)
};
let diagnostics = validate::validate_fixtures_semantic(&fixtures, e2e_config, &resolved_languages);
for diag in &diagnostics {
match diag.severity {
Severity::Error => error!("{}: {}", diag.file, diag.message),
Severity::Warning => warn!("{}: {}", diag.file, diag.message),
}
}
let assertion_recipe_errors: Vec<_> = diagnostics
.iter()
.filter(|diag| diag.severity == Severity::Error && diag.message.contains("requires assertion recipe"))
.collect();
if !assertion_recipe_errors.is_empty() {
bail!(
"e2e fixture assertion recipe validation failed: {}",
assertion_recipe_errors
.iter()
.map(|diag| format!("{}: {}", diag.file, diag.message))
.collect::<Vec<_>>()
.join("; ")
);
}
let classification_diagnostics = validate::validate_field_classifications(e2e_config, type_defs);
for diag in &classification_diagnostics {
match diag.severity {
Severity::Error => error!("{}: {}", diag.file, diag.message),
Severity::Warning => warn!("{}: {}", diag.file, diag.message),
}
}
let classification_errors: Vec<_> = classification_diagnostics
.iter()
.filter(|diag| diag.severity == Severity::Error)
.collect();
if !classification_errors.is_empty() {
bail!(
"e2e field-classification validation failed: {}",
classification_errors
.iter()
.map(|diag| format!("{}: {}", diag.file, diag.message))
.collect::<Vec<_>>()
.join("; ")
);
}
validate_call_class::enforce_call_class_overrides(e2e_config, config, type_defs, enums, &resolved_languages)?;
validate_call_result_type::enforce_call_result_type_overrides(e2e_config, type_defs, enums, &resolved_languages)?;
validate_call_module::enforce_call_module_overrides(e2e_config, config, &resolved_languages)?;
validate_call_args::enforce_call_arg_signatures(&fixtures, e2e_config, functions, type_defs, &resolved_languages)?;
let all_groups = group_fixtures(&fixtures);
let all_groups: Vec<_> = if e2e_config.exclude_categories.is_empty() {
all_groups
} else {
all_groups
.into_iter()
.filter(|g| !e2e_config.exclude_categories.contains(&g.category))
.collect()
};
let groups: Vec<_> =
if e2e_config.dep_mode == DependencyMode::Registry && !e2e_config.registry.categories.is_empty() {
let allowed = &e2e_config.registry.categories;
all_groups
.into_iter()
.filter(|g| allowed.iter().any(|c| c == &g.category))
.collect()
} else {
all_groups
};
let generators = codegen::generators_for(&resolved_languages);
let (mut all_files, generator_failures) = run_generators(
&generators,
&groups,
e2e_config,
config,
type_defs,
enums,
functions,
errors,
);
if let Some(snippet_config) = &e2e_config.snippets {
for docs_only_fixture in &docs_only_fixtures {
let file = fixture::docs_only::render_docs_only_fixture(docs_only_fixture, &snippet_config.output)
.with_context(|| format!("failed to render docs-only fixture `{}`", docs_only_fixture.id))?;
all_files.push(file);
}
}
let mut deferred_error = ensure_no_generator_failures(&generator_failures, generators.len());
let skip_records = codegen::take_skip_records();
if let Some(summary) = codegen::skip_summary(&skip_records) {
info!("{summary}");
}
if let Some(error) = codegen::strict_assertion_failure(&skip_records, codegen::strict_assertions_enabled()) {
warn!("e2e strict-assertion check failed, deferring failure");
deferred_error.get_or_insert(error);
}
let inert_examples = codegen::inert_example::take_inert_examples();
inert_report::report_inert_examples(&inert_examples);
if let Some(summary) = codegen::inert_example::inert_summary(&inert_examples) {
warn!("{summary}");
}
let extension_result = (|| {
for lang in &resolved_languages {
for ext in extensions {
let extra = ext.emit_e2e(&groups, e2e_config, config, lang, type_defs, enums)?;
if !extra.is_empty() {
info!(
" [{}] extension `{}` generated {} e2e file(s)",
lang,
ext.name(),
extra.len()
);
}
all_files.extend(extra);
}
}
Ok::<(), anyhow::Error>(())
})();
if let Err(error) = extension_result {
warn!("e2e extension emission failed, deferring failure: {error:#}");
deferred_error.get_or_insert(error);
}
if let Some(snippet_config) = &e2e_config.snippets {
match snippets::generate_snippet_report(
&fixtures,
snippet_config.languages_or(&resolved_languages),
e2e_config,
snippet_config,
config,
type_defs,
enums,
functions,
errors,
) {
Ok(report) => {
report_snippet_coverage(&report.coverage);
match ensure_snippet_coverage_complete(&report.coverage) {
Ok(()) => prune_orphaned_snippets(Path::new(&snippet_config.output), &report.coverage),
Err(error) => {
warn!(
"snippet coverage incomplete, deferring failure and skipping the orphan prune: {error:#}"
);
deferred_error.get_or_insert(error);
}
}
let coverage_content = serde_json::to_string_pretty(&report.coverage)
.context("failed to serialize snippet coverage manifest")?;
all_files.push(GeneratedFile {
path: Path::new(&snippet_config.output).join(snippets::COVERAGE_MANIFEST),
content: format!("{coverage_content}\n"),
generated_header: false,
});
all_files.extend(report.snippets.into_iter().map(|snippet| snippet.file));
}
Err(error) => {
warn!("snippet generation failed, deferring failure: {error:#}");
deferred_error.get_or_insert(error);
}
}
}
Ok((all_files, deferred_error))
}
#[allow(clippy::too_many_arguments)]
fn run_generators(
generators: &[Box<dyn codegen::E2eCodegen>],
groups: &[fixture::FixtureGroup],
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
type_defs: &[crate::core::ir::TypeDef],
enums: &[crate::core::ir::EnumDef],
functions: &[crate::core::ir::FunctionDef],
errors: &[crate::core::ir::ErrorDef],
) -> (Vec<GeneratedFile>, Vec<String>) {
let mut all_files = Vec::new();
let mut failures = Vec::new();
for generator in generators {
match generator.generate_gated(groups, e2e_config, config, type_defs, enums, functions, errors) {
Ok(files) => {
info!(" [{}] generated {} file(s)", generator.language_name(), files.len());
all_files.extend(files);
}
Err(error) => {
warn!(
" [{}] e2e codegen failed, skipping this backend: {error:#}",
generator.language_name()
);
failures.push(format!("[{}] {error:#}", generator.language_name()));
}
}
}
(all_files, failures)
}
fn ensure_no_generator_failures(failures: &[String], generator_count: usize) -> Option<anyhow::Error> {
if failures.is_empty() {
return None;
}
Some(anyhow::anyhow!(
"e2e codegen failed for {} of {} backend(s) -- other backends and the snippet stage still ran: {}",
failures.len(),
generator_count,
failures.join("; ")
))
}
pub fn report_cached_snippet_coverage(path: &Path) -> Result<()> {
let coverage = coverage_cache::read_coverage_manifest(path)?;
snippets::coverage::validate(&coverage)?;
report_snippet_coverage(&coverage);
ensure_snippet_coverage_complete(&coverage)
}
pub fn evaluate_snippet_coverage(
config: &ResolvedCrateConfig,
e2e_config: &E2eConfig,
type_defs: &[crate::core::ir::TypeDef],
enums: &[crate::core::ir::EnumDef],
functions: &[crate::core::ir::FunctionDef],
) -> Result<Option<snippets::SnippetCoverageLedger>> {
let Some(snippet_config) = &e2e_config.snippets else {
return Ok(None);
};
let fixtures_dir = Path::new(&e2e_config.fixtures);
let fixtures = load_fixtures(fixtures_dir)
.with_context(|| format!("failed to load fixtures from {}", fixtures_dir.display()))?;
let configured_languages = if e2e_config.languages.is_empty() {
default_e2e_languages(&config.languages)
} else {
e2e_config.languages.clone()
};
let report = snippets::generate_snippet_report(
&fixtures,
snippet_config.languages_or(&configured_languages),
e2e_config,
snippet_config,
config,
type_defs,
enums,
functions,
&[],
)?;
Ok(Some(report.coverage))
}
pub fn ensure_fresh_snippet_coverage_complete(coverage: &snippets::SnippetCoverageLedger) -> Result<()> {
snippets::coverage::validate(coverage)?;
report_snippet_coverage(coverage);
ensure_snippet_coverage_complete(coverage)
}
pub fn verify_fresh_snippet_coverage(
base_dir: &Path,
config: &ResolvedCrateConfig,
e2e_config: &E2eConfig,
type_defs: &[crate::core::ir::TypeDef],
enums: &[crate::core::ir::EnumDef],
functions: &[crate::core::ir::FunctionDef],
) -> Result<()> {
let Some(snippet_config) = &e2e_config.snippets else {
return Ok(());
};
let computed = evaluate_snippet_coverage(config, e2e_config, type_defs, enums, functions)?
.expect("snippet configuration produces a coverage ledger");
ensure_fresh_snippet_coverage_complete(&computed)?;
let manifest = base_dir.join(&snippet_config.output).join(snippets::COVERAGE_MANIFEST);
let disk = coverage_cache::read_coverage_manifest(&manifest)?;
snippets::coverage::validate_tracked_files(&disk, &base_dir.join(&snippet_config.output))?;
snippets::coverage::validate_current(disk, computed)
.with_context(|| format!("snippet coverage manifest is stale: {}", manifest.display()))?;
Ok(())
}
fn report_snippet_coverage(coverage: &snippets::SnippetCoverageLedger) {
for missing in &coverage.missing {
warn!(
"snippet coverage missing for fixture `{}` language `{}`: {}",
missing.key.fixture_id, missing.key.language, missing.reason
);
}
}
fn ensure_snippet_coverage_complete(coverage: &snippets::SnippetCoverageLedger) -> Result<()> {
let Some(first) = coverage.missing.first() else {
return Ok(());
};
bail!(
"snippet generation has {} undocumented coverage gap(s); first missing recipe is fixture `{}` language `{}`: {}",
coverage.missing.len(),
first.key.fixture_id,
first.key.language,
first.reason
)
}
fn prune_orphaned_snippets(output_root: &Path, coverage: &snippets::SnippetCoverageLedger) {
let manifest_path = output_root.join(snippets::COVERAGE_MANIFEST);
let Ok(previous) = coverage_cache::read_coverage_manifest(&manifest_path) else {
return;
};
for relative in snippets::coverage::orphaned_paths(&previous, coverage) {
let path = output_root.join(&relative);
match std::fs::remove_file(&path) {
Ok(()) => info!("Pruned orphaned snippet: {}", path.display()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => warn!("failed to prune orphaned snippet {}: {error}", path.display()),
}
}
}
#[cfg(test)]
mod tests;