use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
process::Command as ProcessCommand,
time::SystemTime,
};
use aeri::{
AeriError, CompileOutput, CostReport, LintWarning, TestReport, TestResult,
UplcValidatorArtifact, ValidatorCost,
backend::{
PREVIEW_BLUEPRINT_FILE, PREVIEW_SCRIPT_EXTENSION, Target, UPLC_BLUEPRINT_FILE,
UPLC_SCRIPT_EXTENSION,
},
build_uplc_blueprint, build_uplc_project_blueprint, compile_source, docs_source,
estimate_source, format_source,
inspect::{
ValidatorInspection, inspect_output, inspect_project_outputs,
render_project_terminal_report, render_terminal_report,
},
lint_source, run_tests_source, try_merge_blueprints,
};
use clap::{Parser, Subcommand, ValueEnum};
use serde::Serialize;
#[derive(Debug, Parser)]
#[command(
name = "aeri",
version,
about = "A Cardano-oriented smart contract language"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum TargetArg {
/// Emit Aeri textual IR and preview metadata.
Preview,
/// Emit Cardano UPLC/CBOR artifacts for the supported serializable subset.
Uplc,
}
impl From<TargetArg> for Target {
fn from(target: TargetArg) -> Self {
match target {
TargetArg::Preview => Self::Preview,
TargetArg::Uplc => Self::Uplc,
}
}
}
#[derive(Debug, Subcommand)]
enum Command {
/// Check and compile a source file for a backend target.
Build {
/// Source file to compile.
input: PathBuf,
/// Backend target to emit.
#[arg(long, value_enum, default_value = "preview")]
target: TargetArg,
/// Optional output path. Prints to stdout when omitted.
#[arg(short, long)]
output: Option<PathBuf>,
/// Emit a target blueprint JSON document instead of the default output.
#[arg(long)]
blueprint: bool,
/// Write target blueprint and per-validator artifacts to a directory.
#[arg(long)]
artifacts: Option<PathBuf>,
},
/// Build every .aeri file in a project tree for a backend target.
BuildProject {
/// Project root to scan.
root: PathBuf,
/// Backend target to emit.
#[arg(long, value_enum, default_value = "preview")]
target: TargetArg,
/// Optional output path. Prints to stdout when omitted.
#[arg(short, long)]
output: Option<PathBuf>,
/// Write target blueprint and per-validator artifacts to a directory.
#[arg(long)]
artifacts: Option<PathBuf>,
},
/// Parse and type-check every .aeri file in a project tree.
CheckProject {
/// Project root to scan.
root: PathBuf,
},
/// Parse and type-check a source file without writing output.
Check {
/// Source file to check.
input: PathBuf,
},
/// Run pure test blocks in a source file.
Test {
/// Source file to test.
input: PathBuf,
/// Emit JSON instead of human-readable output.
#[arg(long)]
json: bool,
},
/// Check, test, and lint a source file for release readiness.
Verify {
/// Source file to verify.
input: PathBuf,
/// Backend target to verify for.
#[arg(long, value_enum, default_value = "preview")]
target: TargetArg,
/// Emit JSON instead of human-readable output.
#[arg(long)]
json: bool,
/// Allow lint warnings instead of failing the command.
#[arg(long)]
allow_warnings: bool,
},
/// Deconstruct a source file and inspect backend readiness.
Inspect {
/// Source file to inspect.
input: PathBuf,
/// Backend target to inspect readiness for.
#[arg(long, value_enum, default_value = "preview")]
target: TargetArg,
/// Emit JSON instead of the terminal viewer.
#[arg(long)]
json: bool,
/// Include full preview IR in the output.
#[arg(long)]
ir: bool,
/// Exit with an error if the requested target has blockers.
#[arg(long)]
check: bool,
/// Exit with an error if UPLC-core lowering is blocked.
#[arg(long)]
check_core: bool,
},
/// Deconstruct every .aeri file in a project tree and inspect backend readiness.
InspectProject {
/// Project root to inspect.
root: PathBuf,
/// Backend target to inspect readiness for.
#[arg(long, value_enum, default_value = "preview")]
target: TargetArg,
/// Emit JSON instead of the terminal viewer.
#[arg(long)]
json: bool,
/// Include full preview IR in the output.
#[arg(long)]
ir: bool,
/// Exit with an error if the requested target has blockers.
#[arg(long)]
check: bool,
/// Exit with an error if any UPLC-core lowering is blocked.
#[arg(long)]
check_core: bool,
},
/// Lint a source file for suspicious contract patterns.
Lint {
/// Source file to lint.
input: PathBuf,
/// Emit JSON instead of human-readable output.
#[arg(long)]
json: bool,
/// Exit with an error if warnings are found.
#[arg(long)]
deny_warnings: bool,
},
/// Generate Markdown documentation for a source file.
Docs {
/// Source file to document.
input: PathBuf,
/// Optional output path. Prints to stdout when omitted.
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Estimate generated validator size and budget.
Cost {
/// Source file to estimate.
input: PathBuf,
/// Emit JSON instead of a table.
#[arg(long)]
json: bool,
},
/// Format a source file.
Fmt {
/// Source file to format.
input: PathBuf,
/// Overwrite the source file with formatted output.
#[arg(short, long)]
write: bool,
/// Fail when the source is not already formatted.
#[arg(long)]
check: bool,
},
/// Format every .aeri file in a project tree.
FmtProject {
/// Project root to scan.
root: PathBuf,
/// Overwrite source files with formatted output.
#[arg(short, long)]
write: bool,
/// Fail when any source file is not already formatted.
#[arg(long)]
check: bool,
},
/// Run pure test blocks in every .aeri file in a project tree.
TestProject {
/// Project root to scan.
root: PathBuf,
/// Emit JSON instead of human-readable output.
#[arg(long)]
json: bool,
},
/// Check, test, and lint a project tree for release readiness.
VerifyProject {
/// Project root to verify.
root: PathBuf,
/// Backend target to verify for.
#[arg(long, value_enum, default_value = "preview")]
target: TargetArg,
/// Emit JSON instead of human-readable output.
#[arg(long)]
json: bool,
/// Allow lint warnings instead of failing the command.
#[arg(long)]
allow_warnings: bool,
},
/// Lint every .aeri file in a project tree.
LintProject {
/// Project root to scan.
root: PathBuf,
/// Emit JSON instead of human-readable output.
#[arg(long)]
json: bool,
/// Exit with an error if warnings are found.
#[arg(long)]
deny_warnings: bool,
},
/// Generate Markdown documentation for every .aeri file in a project tree.
DocsProject {
/// Project root to document.
root: PathBuf,
/// Optional output path. Prints to stdout when omitted.
#[arg(short, long)]
output: Option<PathBuf>,
},
/// Estimate generated validator size and budget for a project tree.
CostProject {
/// Project root to scan.
root: PathBuf,
/// Emit JSON instead of a table.
#[arg(long)]
json: bool,
},
/// Create a new Aeri project skeleton.
New {
/// Project name.
name: String,
/// Destination directory. Defaults to the project name.
path: Option<PathBuf>,
},
}
fn main() {
if let Err(error) = run() {
eprintln!("error: {error}");
std::process::exit(1);
}
}
fn run() -> aeri::Result<()> {
run_cli(Cli::parse())
}
fn run_cli(cli: Cli) -> aeri::Result<()> {
match cli.command {
Command::Build {
input,
target,
output,
blueprint,
artifacts,
} => {
let file = input.to_string_lossy().to_string();
ensure_supported_target(target, &file)?;
let source = fs::read_to_string(&input)?;
let compiled = compile_source(&file, &source)?;
let target = Target::from(target);
let build = build_output_for_target(&file, &compiled, target, blueprint)?;
if let Some(artifacts) = &artifacts {
checked_artifact_names_for_target(
artifacts,
target,
&build.blueprint,
&build.uplc_validators,
)?;
}
if let Some(output) = output {
fs::write(output, &build.rendered)?;
} else if artifacts.is_none() {
println!("{}", build.rendered);
}
if let Some(artifacts) = artifacts {
write_artifacts(&artifacts, target, &build.blueprint, &build.uplc_validators)?;
println!("wrote artifacts to {}", artifacts.display());
}
}
Command::Check { input } => {
let source = fs::read_to_string(&input)?;
let compiled = compile_source(&input.to_string_lossy(), &source)?;
println!(
"checked {} validator(s), {} function(s), {} test(s)",
compiled.validator_count, compiled.function_count, compiled.test_count
);
}
Command::Test { input, json } => {
let file = input.to_string_lossy().to_string();
let source = fs::read_to_string(&input)?;
let report = run_tests_source(&file, &source)?;
let failed = report.failed();
if json {
let report = file_test_report(file.clone(), report);
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_test_report(&file, &report);
}
fail_on_test_failures(&file, failed)?;
}
Command::Verify {
input,
target,
json,
allow_warnings,
} => {
let file = input.to_string_lossy().to_string();
ensure_supported_target(target, &file)?;
let source = fs::read_to_string(&input)?;
let compiled = compile_source(&file, &source)?;
let uplc_validators = if Target::from(target) == Target::Uplc {
let (_, validators) = build_uplc_blueprint(&file, &compiled)?;
verify_cardano_cli_scripts(&file, &validators)?;
validators.len()
} else {
0
};
let check = check_summary(1, std::slice::from_ref(&compiled));
let tests = file_test_report(file.clone(), run_tests_source(&file, &source)?);
let warnings = lint_source(&file, &source)?;
let failed_tests = tests.failed;
let warning_count = warnings.len();
let report = FileVerifyReport {
file: file.clone(),
check,
tests,
warnings,
passed: failed_tests == 0 && (allow_warnings || warning_count == 0),
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_check_summary(&report.check);
print_file_test_report(&report.tests);
print_lint_warnings(&report.warnings, false)?;
}
fail_on_test_failures(&file, failed_tests)?;
fail_on_lint_warnings(&file, !allow_warnings, warning_count)?;
if !json {
println!("verified {}", input.display());
if uplc_validators > 0 {
println!("validated {uplc_validators} UPLC script hash(es) with cardano-cli");
}
}
}
Command::Inspect {
input,
target,
json,
ir,
check,
check_core,
} => {
let file = input.to_string_lossy().to_string();
let target = Target::from(target);
let source = fs::read_to_string(&input)?;
let compiled = compile_source(&file, &source)?;
let report = inspect_output(file.clone(), &compiled, target, ir);
let blocked = report.has_blockers();
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", render_terminal_report(&report));
}
if check && blocked {
return Err(AeriError::at(
file,
1,
1,
format!("target '{}' has inspection blocker(s)", target.name()),
));
}
fail_on_core_inspection_blockers(target, check_core, &file, &report.validators)?;
}
Command::InspectProject {
root,
target,
json,
ir,
check,
check_core,
} => {
let target = Target::from(target);
let root_label = root.to_string_lossy().to_string();
let (files, compiled) = compile_project(&root)?;
let file_labels = files
.iter()
.map(|file| file.to_string_lossy().to_string())
.collect::<Vec<_>>();
let report =
inspect_project_outputs(root_label.clone(), &file_labels, &compiled, target, ir);
let blocked = report.has_blockers();
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", render_project_terminal_report(&report));
}
if check && blocked {
return Err(AeriError::at(
root_label,
1,
1,
format!(
"target '{}' has project inspection blocker(s)",
target.name()
),
));
}
let validators = report
.files
.iter()
.flat_map(|file| file.validators.iter())
.collect::<Vec<_>>();
fail_on_core_inspection_blockers(target, check_core, &root_label, validators)?;
}
Command::Lint {
input,
json,
deny_warnings,
} => {
let file = input.to_string_lossy().to_string();
let source = fs::read_to_string(&input)?;
let warnings = lint_source(&file, &source)?;
print_lint_warnings(&warnings, json)?;
fail_on_lint_warnings(&file, deny_warnings, warnings.len())?;
}
Command::Docs { input, output } => {
let file = input.to_string_lossy().to_string();
let source = fs::read_to_string(&input)?;
let docs = docs_source(&file, &source)?;
write_or_print(output, docs)?;
}
Command::Cost { input, json } => {
let file = input.to_string_lossy().to_string();
let source = fs::read_to_string(&input)?;
let report = estimate_source(&file, &source)?;
print_cost_report(&report, json)?;
}
Command::Fmt {
input,
write,
check,
} => {
let file = input.to_string_lossy().to_string();
let source = fs::read_to_string(&input)?;
let formatted = format_source(&file, &source)?;
if check {
if source != formatted {
return Err(AeriError::at(file, 1, 1, "source is not formatted"));
}
println!("{} is formatted", input.display());
} else if write {
fs::write(input, formatted)?;
} else {
print!("{formatted}");
}
}
Command::CheckProject { root } => {
let (files, compiled) = compile_project(&root)?;
print_project_check_summary(files.len(), &compiled);
}
Command::BuildProject {
root,
target,
output,
artifacts,
} => {
let root_label = root.to_string_lossy().to_string();
ensure_supported_target(target, &root_label)?;
let (files, compiled) = compile_project(&root)?;
let file_labels = files
.iter()
.map(|file| file.to_string_lossy().to_string())
.collect::<Vec<_>>();
let manifest = read_manifest(&root)?;
let title = manifest
.name
.as_deref()
.unwrap_or_else(|| project_title(&root));
let target = Target::from(target);
let (mut blueprint, uplc_validators) = match target {
Target::Preview => (try_merge_blueprints(title, &compiled)?, Vec::new()),
Target::Uplc => build_uplc_project_blueprint(title, &file_labels, &compiled)?,
};
if let Some(version) = manifest.version {
blueprint.preamble.version = version;
}
let rendered = serde_json::to_string_pretty(&blueprint)?;
if let Some(artifacts) = &artifacts {
checked_artifact_names_for_target(artifacts, target, &blueprint, &uplc_validators)?;
}
if let Some(output) = output {
fs::write(output, rendered)?;
} else if artifacts.is_none() {
println!("{rendered}");
}
if let Some(artifacts) = artifacts {
write_artifacts(&artifacts, target, &blueprint, &uplc_validators)?;
println!("wrote artifacts to {}", artifacts.display());
}
}
Command::FmtProject { root, write, check } => {
let files = project_files(&root)?;
for file in files {
let path = file.to_string_lossy().to_string();
let source = fs::read_to_string(&file)?;
let formatted = format_source(&path, &source)?;
if check {
if source != formatted {
return Err(AeriError::at(path, 1, 1, "source is not formatted"));
}
} else if write {
fs::write(&file, formatted)?;
} else {
println!("// {}\n{formatted}", file.display());
}
}
if check {
println!("{} is formatted", root.display());
}
}
Command::TestProject { root, json } => {
let files = project_files(&root)?;
if json {
let report = collect_project_test_report(&files)?;
let failed = report.failed;
println!("{}", serde_json::to_string_pretty(&report)?);
fail_on_project_test_failures(&root, failed)?;
} else {
let (_passed, failed) = run_tests_for_files(&files)?;
fail_on_project_test_failures(&root, failed)?;
}
}
Command::VerifyProject {
root,
target,
json,
allow_warnings,
} => {
let root_label = root.to_string_lossy().to_string();
ensure_supported_target(target, &root_label)?;
let (files, compiled) = compile_project(&root)?;
let uplc_validators = if Target::from(target) == Target::Uplc {
let file_labels = files
.iter()
.map(|file| file.to_string_lossy().to_string())
.collect::<Vec<_>>();
let (_, validators) =
build_uplc_project_blueprint(&root_label, &file_labels, &compiled)?;
verify_cardano_cli_scripts(&root_label, &validators)?;
validators.len()
} else {
0
};
let check = check_summary(files.len(), &compiled);
let tests = collect_project_test_report(&files)?;
let warnings = lint_files(&files)?;
let failed_tests = tests.failed;
let warning_count = warnings.len();
let report = ProjectVerifyReport {
root: root.to_string_lossy().to_string(),
check,
tests,
warnings,
passed: failed_tests == 0 && (allow_warnings || warning_count == 0),
};
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_check_summary(&report.check);
print_project_test_report(&report.tests);
print_lint_warnings(&report.warnings, false)?;
}
fail_on_project_test_failures(&root, failed_tests)?;
fail_on_lint_warnings(&root.to_string_lossy(), !allow_warnings, warning_count)?;
if !json {
println!("verified {}", root.display());
if uplc_validators > 0 {
println!("validated {uplc_validators} UPLC script hash(es) with cardano-cli");
}
}
}
Command::LintProject {
root,
json,
deny_warnings,
} => {
let files = project_files(&root)?;
let warnings = lint_files(&files)?;
print_lint_warnings(&warnings, json)?;
fail_on_lint_warnings(&root.to_string_lossy(), deny_warnings, warnings.len())?;
}
Command::DocsProject { root, output } => {
let files = project_files(&root)?;
let mut docs = format!("# Aeri Project `{}`\n\n", project_title(&root));
for file in files {
let label = file.to_string_lossy().to_string();
let source = fs::read_to_string(&file)?;
docs.push_str(&format!("<!-- {} -->\n\n", file.display()));
docs.push_str(&docs_source(&label, &source)?);
docs.push('\n');
}
write_or_print(output, docs)?;
}
Command::CostProject { root, json } => {
let files = project_files(&root)?;
let mut validators = Vec::new();
for file in files {
let label = file.to_string_lossy().to_string();
let source = fs::read_to_string(&file)?;
validators.extend(estimate_source(&label, &source)?.validators);
}
let report = aeri::cost::report(validators);
print_cost_report(&report, json)?;
}
Command::New { name, path } => {
let root = path.unwrap_or_else(|| PathBuf::from(&name));
create_project(&name, &root)?;
println!("created {}", root.display());
}
}
Ok(())
}
fn ensure_supported_target(target: TargetArg, label: &str) -> aeri::Result<()> {
Target::from(target).ensure_supported(label)
}
fn fail_on_core_inspection_blockers<'a>(
target: Target,
check_core: bool,
label: &str,
validators: impl IntoIterator<Item = &'a ValidatorInspection>,
) -> aeri::Result<()> {
if !check_core {
return Ok(());
}
if target != Target::Uplc {
return Err(AeriError::at(
label,
1,
1,
"--check-core requires '--target uplc'",
));
}
let blocked = validators.into_iter().any(|validator| {
validator
.uplc_core
.as_ref()
.is_none_or(|uplc_core| !uplc_core.lowered || !uplc_core.validated)
});
if blocked {
return Err(AeriError::at(
label,
1,
1,
"UPLC-core lowering blocker(s) found",
));
}
Ok(())
}
fn compile_project(root: &Path) -> aeri::Result<(Vec<PathBuf>, Vec<aeri::CompileOutput>)> {
let files = project_files(root)?;
let mut compiled = Vec::with_capacity(files.len());
for file in &files {
let source = fs::read_to_string(file)?;
compiled.push(compile_source(&file.to_string_lossy(), &source)?);
}
validate_project_blueprints(&files, &compiled)?;
Ok((files, compiled))
}
fn validate_project_blueprints(files: &[PathBuf], outputs: &[CompileOutput]) -> aeri::Result<()> {
let mut validators = HashMap::<String, &PathBuf>::new();
let mut definitions = HashMap::<String, (aeri::compile::BlueprintDefinition, &PathBuf)>::new();
for (file, output) in files.iter().zip(outputs) {
for validator in &output.blueprint.validators {
if let Some(first_file) = validators.insert(validator.title.clone(), file) {
return Err(AeriError::at(
file.to_string_lossy(),
1,
1,
format!(
"duplicate validator title '{}'; first emitted by {}",
validator.title,
first_file.display()
),
));
}
}
for definition in &output.blueprint.definitions {
if let Some((first_definition, first_file)) = definitions.get(&definition.title) {
if first_definition != definition {
return Err(AeriError::at(
file.to_string_lossy(),
1,
1,
format!(
"conflicting blueprint definition '{}'; first emitted by {}",
definition.title,
first_file.display()
),
));
}
} else {
definitions.insert(definition.title.clone(), (definition.clone(), file));
}
}
}
Ok(())
}
#[derive(Debug, Serialize)]
struct CheckSummary {
files: usize,
validators: usize,
functions: usize,
tests: usize,
}
#[derive(Debug, Serialize)]
struct FileVerifyReport {
file: String,
check: CheckSummary,
tests: FileTestReport,
warnings: Vec<LintWarning>,
passed: bool,
}
#[derive(Debug, Serialize)]
struct ProjectVerifyReport {
root: String,
check: CheckSummary,
tests: ProjectTestReport,
warnings: Vec<LintWarning>,
passed: bool,
}
fn check_summary(files: usize, compiled: &[CompileOutput]) -> CheckSummary {
let validators = compiled
.iter()
.map(|output| output.validator_count)
.sum::<usize>();
let functions = compiled
.iter()
.map(|output| output.function_count)
.sum::<usize>();
let tests = compiled
.iter()
.map(|output| output.test_count)
.sum::<usize>();
CheckSummary {
files,
validators,
functions,
tests,
}
}
fn print_project_check_summary(files: usize, compiled: &[CompileOutput]) {
let summary = check_summary(files, compiled);
print_check_summary(&summary);
}
fn print_check_summary(summary: &CheckSummary) {
println!(
"checked {} file(s), {} validator(s), {} function(s), {} test(s)",
summary.files, summary.validators, summary.functions, summary.tests
);
}
#[derive(Debug, Serialize)]
struct FileTestReport {
file: String,
results: Vec<TestResult>,
passed: usize,
failed: usize,
}
#[derive(Debug, Serialize)]
struct ProjectTestReport {
files: Vec<FileTestReport>,
passed: usize,
failed: usize,
}
fn file_test_report(file: String, report: TestReport) -> FileTestReport {
let passed = report.passed();
let failed = report.failed();
FileTestReport {
file,
results: report.results,
passed,
failed,
}
}
fn collect_project_test_report(files: &[PathBuf]) -> aeri::Result<ProjectTestReport> {
let mut file_reports = Vec::new();
let mut passed = 0;
let mut failed = 0;
for file in files {
let label = file.to_string_lossy().to_string();
let source = fs::read_to_string(file)?;
let report = file_test_report(label.clone(), run_tests_source(&label, &source)?);
passed += report.passed;
failed += report.failed;
file_reports.push(report);
}
Ok(ProjectTestReport {
files: file_reports,
passed,
failed,
})
}
fn run_tests_for_files(files: &[PathBuf]) -> aeri::Result<(usize, usize)> {
let report = collect_project_test_report(files)?;
print_project_test_report(&report);
Ok((report.passed, report.failed))
}
fn lint_files(files: &[PathBuf]) -> aeri::Result<Vec<LintWarning>> {
let mut warnings = Vec::new();
for file in files {
let label = file.to_string_lossy().to_string();
let source = fs::read_to_string(file)?;
warnings.extend(lint_source(&label, &source)?);
}
Ok(warnings)
}
fn write_or_print(output: Option<PathBuf>, content: String) -> aeri::Result<()> {
if let Some(output) = output {
fs::write(output, content)?;
} else {
print!("{content}");
}
Ok(())
}
struct TargetBuildOutput {
rendered: String,
blueprint: aeri::compile::Blueprint,
uplc_validators: Vec<UplcValidatorArtifact>,
}
fn build_output_for_target(
file: &str,
compiled: &CompileOutput,
target: Target,
blueprint_requested: bool,
) -> aeri::Result<TargetBuildOutput> {
match target {
Target::Preview => Ok(TargetBuildOutput {
rendered: if blueprint_requested {
serde_json::to_string_pretty(&compiled.blueprint)?
} else {
compiled.script.clone()
},
blueprint: compiled.blueprint.clone(),
uplc_validators: Vec::new(),
}),
Target::Uplc => {
let (blueprint, validators) = build_uplc_blueprint(file, compiled)?;
let rendered = if blueprint_requested {
serde_json::to_string_pretty(&blueprint)?
} else {
render_uplc_scripts(&validators)?
};
Ok(TargetBuildOutput {
rendered,
blueprint,
uplc_validators: validators,
})
}
}
}
fn render_uplc_scripts(validators: &[UplcValidatorArtifact]) -> aeri::Result<String> {
if validators.len() == 1 {
return Ok(serde_json::to_string_pretty(
&validators[0].ledger.script_json,
)?);
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ScriptEntry<'a> {
title: &'a str,
script_purpose: &'a str,
hash: &'a str,
script: &'a aeri::uplc::PlutusScriptJson,
}
let entries = validators
.iter()
.map(|validator| ScriptEntry {
title: &validator.title,
script_purpose: &validator.script_purpose,
hash: &validator.ledger.script_hash,
script: &validator.ledger.script_json,
})
.collect::<Vec<_>>();
Ok(serde_json::to_string_pretty(&entries)?)
}
fn verify_cardano_cli_scripts(
label: &str,
validators: &[UplcValidatorArtifact],
) -> aeri::Result<()> {
let cli = std::env::var("AERI_CARDANO_CLI").unwrap_or_else(|_| "cardano-cli".to_string());
verify_cardano_cli_scripts_with(label, validators, &cli)
}
fn verify_cardano_cli_scripts_with(
label: &str,
validators: &[UplcValidatorArtifact],
cli: &str,
) -> aeri::Result<()> {
if validators.is_empty() {
return Ok(());
}
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-cardano-cli-{unique}"));
fs::create_dir_all(&root)?;
let result = (|| {
for validator in validators {
let path = root.join(format!(
"{}.{}",
safe_file_name(&validator.title),
UPLC_SCRIPT_EXTENSION
));
fs::write(
&path,
serde_json::to_string_pretty(&validator.ledger.script_json)?,
)?;
let output = ProcessCommand::new(cli)
.args(["transaction", "policyid", "--script-file"])
.arg(&path)
.output()
.map_err(|error| {
AeriError::at(
label,
1,
1,
format!("failed to run cardano-cli for UPLC validation: {error}"),
)
})?;
if !output.status.success() {
return Err(AeriError::at(
label,
1,
1,
format!(
"cardano-cli rejected UPLC script '{}': {}",
validator.title,
String::from_utf8_lossy(&output.stderr).trim()
),
));
}
let actual = String::from_utf8_lossy(&output.stdout).trim().to_string();
if actual != validator.ledger.script_hash {
return Err(AeriError::at(
label,
1,
1,
format!(
"cardano-cli hash mismatch for '{}': expected {}, got {}",
validator.title, validator.ledger.script_hash, actual
),
));
}
}
Ok(())
})();
let cleanup = fs::remove_dir_all(&root);
match (result, cleanup) {
(Ok(()), Ok(())) => Ok(()),
(Ok(()), Err(error)) => Err(error.into()),
(Err(error), _) => Err(error),
}
}
fn print_cost_report(report: &CostReport, json: bool) -> aeri::Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(report)?);
return Ok(());
}
println!("validator\tpurpose\tbytes\tnodes\tbudget");
for ValidatorCost {
title,
script_purpose,
bytes,
nodes,
budget_units,
} in &report.validators
{
println!("{title}\t{script_purpose}\t{bytes}\t{nodes}\t{budget_units}");
}
println!(
"total\t-\t{}\t{}\t{}",
report.total_bytes, report.total_nodes, report.total_budget_units
);
Ok(())
}
fn print_test_report(label: &str, report: &TestReport) {
for result in &report.results {
let status = if result.passed { "ok" } else { "FAILED" };
let expectation = if result.expected_failure {
" (expected failure)"
} else {
""
};
if let Some(failure) = &result.failure {
println!("{status} {label}::{}{expectation} - {failure}", result.name);
} else {
println!("{status} {label}::{}{expectation}", result.name);
}
}
if report.results.is_empty() {
println!("no tests in {label}");
}
}
fn print_file_test_report(report: &FileTestReport) {
for result in &report.results {
let status = if result.passed { "ok" } else { "FAILED" };
let expectation = if result.expected_failure {
" (expected failure)"
} else {
""
};
if let Some(failure) = &result.failure {
println!(
"{status} {}::{}{expectation} - {failure}",
report.file, result.name
);
} else {
println!("{status} {}::{}{expectation}", report.file, result.name);
}
}
if report.results.is_empty() {
println!("no tests in {}", report.file);
}
}
fn print_project_test_report(report: &ProjectTestReport) {
for file_report in &report.files {
print_file_test_report(file_report);
}
println!(
"project tests: {} passed, {} failed",
report.passed, report.failed
);
}
fn fail_on_test_failures(file: &str, failed: usize) -> aeri::Result<()> {
if failed == 0 {
return Ok(());
}
Err(AeriError::at(
file,
1,
1,
format!("{failed} test(s) failed"),
))
}
fn fail_on_project_test_failures(root: &Path, failed: usize) -> aeri::Result<()> {
if failed == 0 {
return Ok(());
}
Err(AeriError::at(
root.to_string_lossy(),
1,
1,
format!("{failed} project test(s) failed"),
))
}
fn print_lint_warnings(warnings: &[LintWarning], json: bool) -> aeri::Result<()> {
if json {
println!("{}", serde_json::to_string_pretty(warnings)?);
return Ok(());
}
if warnings.is_empty() {
println!("no lint warnings");
return Ok(());
}
for warning in warnings {
println!(
"warning: {}:{}:{}: {}",
warning.file, warning.line, warning.column, warning.message
);
}
Ok(())
}
fn fail_on_lint_warnings(file: &str, deny_warnings: bool, warnings: usize) -> aeri::Result<()> {
if !deny_warnings || warnings == 0 {
return Ok(());
}
Err(AeriError::at(
file,
1,
1,
format!("{warnings} lint warning(s) found"),
))
}
#[derive(Debug, Default)]
struct ProjectManifest {
name: Option<String>,
version: Option<String>,
}
fn read_manifest(root: &Path) -> aeri::Result<ProjectManifest> {
let path = root.join("aeri.toml");
if !path.exists() {
return Ok(ProjectManifest::default());
}
let source = fs::read_to_string(&path)?;
let mut manifest = ProjectManifest::default();
for line in source.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
let value = value.trim().trim_matches('"').to_string();
match key {
"name" => manifest.name = Some(value),
"version" => manifest.version = Some(value),
_ => (),
}
}
Ok(manifest)
}
fn project_title(root: &Path) -> &str {
root.file_name()
.and_then(|name| name.to_str())
.unwrap_or("aeri-project")
}
fn write_artifacts(
root: &Path,
target: Target,
blueprint: &aeri::compile::Blueprint,
uplc_validators: &[UplcValidatorArtifact],
) -> aeri::Result<()> {
let scripts = root.join("scripts");
let names = checked_artifact_names_for_target(root, target, blueprint, uplc_validators)?;
match target {
Target::Preview => {
fs::create_dir_all(&scripts)?;
fs::write(
root.join(PREVIEW_BLUEPRINT_FILE),
serde_json::to_string_pretty(blueprint)?,
)?;
for (validator, name) in blueprint.validators.iter().zip(names) {
fs::write(
scripts.join(format!("{name}.{PREVIEW_SCRIPT_EXTENSION}")),
&validator.compiled_code,
)?;
}
}
Target::Uplc => {
fs::create_dir_all(&scripts)?;
fs::write(
root.join(UPLC_BLUEPRINT_FILE),
serde_json::to_string_pretty(blueprint)?,
)?;
for (validator, name) in uplc_validators.iter().zip(names) {
fs::write(
scripts.join(format!("{name}.{UPLC_SCRIPT_EXTENSION}")),
serde_json::to_string_pretty(&validator.ledger.script_json)?,
)?;
fs::write(scripts.join(format!("{name}.uplc")), &validator.ledger.text)?;
fs::write(
scripts.join(format!("{name}.flat.hex")),
&validator.ledger.flat_hex,
)?;
fs::write(
scripts.join(format!("{name}.cbor.hex")),
&validator.ledger.cbor_hex,
)?;
}
}
}
Ok(())
}
fn checked_artifact_names_for_target(
root: &Path,
target: Target,
blueprint: &aeri::compile::Blueprint,
uplc_validators: &[UplcValidatorArtifact],
) -> aeri::Result<Vec<String>> {
match target {
Target::Preview => checked_artifact_names(
root,
blueprint
.validators
.iter()
.map(|validator| validator.title.as_str()),
),
Target::Uplc => checked_artifact_names(
root,
uplc_validators
.iter()
.map(|validator| validator.title.as_str()),
),
}
}
fn checked_artifact_names<'a>(
root: &Path,
titles: impl IntoIterator<Item = &'a str>,
) -> aeri::Result<Vec<String>> {
let mut seen = HashMap::new();
let mut names = Vec::new();
for title in titles {
let name = safe_file_name(title);
if let Some(existing) = seen.insert(name.clone(), title.to_string()) {
return Err(AeriError::at(
root.to_string_lossy(),
1,
1,
format!(
"artifact file name collision: validators '{existing}' and '{title}' both map to '{name}'"
),
));
}
names.push(name);
}
Ok(names)
}
fn safe_file_name(title: &str) -> String {
title
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect()
}
fn project_files(root: &Path) -> aeri::Result<Vec<PathBuf>> {
let mut files = Vec::new();
collect_aeri_files(root, &mut files)?;
files.sort();
if files.is_empty() {
return Err(AeriError::at(
root.to_string_lossy(),
1,
1,
"project contains no .aeri files",
));
}
Ok(files)
}
fn collect_aeri_files(path: &Path, files: &mut Vec<PathBuf>) -> aeri::Result<()> {
if path.is_file() {
if path
.extension()
.is_some_and(|extension| extension == "aeri")
{
files.push(path.to_path_buf());
}
return Ok(());
}
for entry in fs::read_dir(path)? {
let entry = entry?;
let path = entry.path();
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if is_ignored_project_dir(file_name) {
continue;
}
if path.is_dir() {
collect_aeri_files(&path, files)?;
} else if path
.extension()
.is_some_and(|extension| extension == "aeri")
{
files.push(path);
}
}
Ok(())
}
fn is_ignored_project_dir(name: &str) -> bool {
matches!(
name,
".git" | ".aeri" | "target" | "build" | "dist" | "node_modules"
)
}
fn create_project(name: &str, root: &Path) -> aeri::Result<()> {
if root.exists() && !is_empty_dir(root)? {
return Err(AeriError::at(
root.to_string_lossy(),
1,
1,
"destination already exists and is not empty",
));
}
let validators = root.join("validators");
fs::create_dir_all(&validators)?;
let module = sanitize_module_name(name);
fs::write(
root.join("aeri.toml"),
format!("name = \"{name}\"\nversion = \"0.1.0\"\n"),
)?;
fs::write(
root.join("README.md"),
format!(
"# {name}\n\nAeri smart contract project.\n\n## Commands\n\n```sh\naeri verify-project . --target preview\naeri inspect validators/main.aeri --target preview --ir\naeri inspect-project . --target uplc --json\naeri build-project . --target preview --artifacts build\naeri docs-project . --output build/CONTRACTS.md\naeri cost-project .\n```\n"
),
)?;
let validator_source = format!(
"module {module};\n\nconst DEMO_OWNER: ByteArray = #11111111111111111111111111111111111111111111111111111111;\n\ntype Action {{\n Spend(owner: ByteArray)\n}}\n\nvalidator spend(datum: Data, action: Action, ctx: Tx) {{\n require tx_has_datum(ctx, datum);\n\n match action {{\n Spend(owner) => tx_signed_by(ctx, owner)\n }}\n}}\n\ntest spend_action_matches {{\n let datum = test_data(#d00d);\n let ctx: Tx = test_tx([DEMO_OWNER], [], [], [], [], 0, 10, [], [datum]);\n let action_matches = match Spend(DEMO_OWNER) {{\n Spend(owner) => owner == DEMO_OWNER\n }};\n\n action_matches && tx_has_datum(ctx, datum) && tx_signed_by(ctx, DEMO_OWNER)\n}}\n"
);
let validator_source = format_source("validators/main.aeri", &validator_source)?;
fs::write(validators.join("main.aeri"), validator_source)?;
Ok(())
}
fn is_empty_dir(path: &Path) -> aeri::Result<bool> {
if !path.is_dir() {
return Ok(false);
}
Ok(fs::read_dir(path)?.next().is_none())
}
fn sanitize_module_name(name: &str) -> String {
let mut module = name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '_' {
ch
} else {
'_'
}
})
.collect::<String>();
if module.is_empty() || module.chars().next().is_some_and(|ch| ch.is_ascii_digit()) {
module.insert(0, '_');
}
module
}
#[cfg(test)]
mod tests {
use std::{fs, time::SystemTime};
use clap::{CommandFactory, Parser as _};
use super::*;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn build_target_defaults_to_preview() {
let cli =
Cli::try_parse_from(["aeri", "build", "contract.aeri"]).expect("build command parses");
match cli.command {
Command::Build { target, .. } => assert_eq!(target, TargetArg::Preview),
_ => panic!("expected build command"),
}
}
#[test]
fn build_project_target_parses_uplc() {
let cli = Cli::try_parse_from(["aeri", "build-project", "contracts", "--target", "uplc"])
.expect("build-project command parses");
match cli.command {
Command::BuildProject { target, .. } => assert_eq!(target, TargetArg::Uplc),
_ => panic!("expected build-project command"),
}
}
#[test]
fn verify_target_defaults_to_preview() {
let cli = Cli::try_parse_from(["aeri", "verify", "contract.aeri"])
.expect("verify command parses");
match cli.command {
Command::Verify { target, .. } => assert_eq!(target, TargetArg::Preview),
_ => panic!("expected verify command"),
}
}
#[test]
fn inspect_target_parses_uplc_check() {
let cli = Cli::try_parse_from([
"aeri",
"inspect",
"contract.aeri",
"--target",
"uplc",
"--check",
])
.expect("inspect command parses");
match cli.command {
Command::Inspect { target, check, .. } => {
assert_eq!(target, TargetArg::Uplc);
assert!(check);
}
_ => panic!("expected inspect command"),
}
}
#[test]
fn inspect_target_parses_uplc_check_core() {
let cli = Cli::try_parse_from([
"aeri",
"inspect",
"contract.aeri",
"--target",
"uplc",
"--check-core",
])
.expect("inspect command parses");
match cli.command {
Command::Inspect {
target, check_core, ..
} => {
assert_eq!(target, TargetArg::Uplc);
assert!(check_core);
}
_ => panic!("expected inspect command"),
}
}
#[test]
fn inspect_project_target_parses_uplc_check() {
let cli = Cli::try_parse_from([
"aeri",
"inspect-project",
"contracts",
"--target",
"uplc",
"--check",
])
.expect("inspect-project command parses");
match cli.command {
Command::InspectProject { target, check, .. } => {
assert_eq!(target, TargetArg::Uplc);
assert!(check);
}
_ => panic!("expected inspect-project command"),
}
}
#[test]
fn uplc_build_target_is_supported_for_serializable_subset() {
ensure_supported_target(TargetArg::Uplc, "contract.aeri")
.expect("uplc target can be attempted");
}
#[test]
fn uplc_file_build_writes_script_json_for_supported_subset() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-uplc-file-target-{unique}"));
fs::create_dir_all(&root).expect("create temp dir");
let input = root.join("contract.aeri");
let output = root.join("contract.plutus.json");
fs::write(
&input,
"module demo;\n\nvalidator gate(datum: Data, redeemer: ByteArray, ctx: Tx) { redeemer == #01 }\n",
)
.expect("write uplc source");
run_cli(Cli {
command: Command::Build {
input,
target: TargetArg::Uplc,
output: Some(output.clone()),
blueprint: false,
artifacts: None,
},
})
.expect("uplc target writes supported script output");
let rendered = fs::read_to_string(&output).expect("read script JSON");
assert!(rendered.contains("PlutusScriptV2"));
assert!(rendered.contains("cborHex"));
fs::remove_dir_all(root).expect("remove temp dir");
}
#[test]
fn uplc_file_build_writes_script_json_for_constructor_subset() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-uplc-constructor-target-{unique}"));
fs::create_dir_all(&root).expect("create temp dir");
let input = root.join("constructor.aeri");
let output = root.join("constructor.plutus.json");
fs::write(
&input,
r#"module demo;
type Action {
Spend(owner: ByteArray),
Close
}
validator gate(redeemer: ByteArray, ctx: Tx) {
let matched = match Spend(redeemer) {
Spend(owner) => owner == redeemer,
Close => false
};
matched &&
Spend(redeemer) == Spend(redeemer) &&
[Spend(redeemer), Close] == [Spend(redeemer), Close] &&
list_len([Spend(redeemer), Close]) == 2
}
"#,
)
.expect("write constructor uplc source");
run_cli(Cli {
command: Command::Build {
input,
target: TargetArg::Uplc,
output: Some(output.clone()),
blueprint: false,
artifacts: None,
},
})
.expect("uplc target writes constructor script output");
let rendered = fs::read_to_string(&output).expect("read script JSON");
let json: serde_json::Value = serde_json::from_str(&rendered).expect("valid script JSON");
assert_eq!(json["type"], "PlutusScriptV2");
assert!(
json["cborHex"]
.as_str()
.expect("cborHex is a string")
.starts_with('5')
);
fs::remove_dir_all(root).expect("remove temp dir");
}
#[test]
fn uplc_project_build_writes_uplc_artifacts_for_supported_subset() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-uplc-project-target-{unique}"));
let artifacts = root.join("artifacts");
fs::create_dir_all(&root).expect("create project dir");
fs::write(
root.join("main.aeri"),
"module demo;\n\nvalidator gate(datum: Data, redeemer: ByteArray, ctx: Tx) { redeemer == #01 }\n",
)
.expect("write project source");
run_cli(Cli {
command: Command::BuildProject {
root: root.clone(),
target: TargetArg::Uplc,
output: None,
artifacts: Some(artifacts.clone()),
},
})
.expect("uplc project artifacts are written");
assert!(artifacts.join(UPLC_BLUEPRINT_FILE).exists());
assert!(artifacts.join("scripts/demo_gate.plutus.json").exists());
assert!(artifacts.join("scripts/demo_gate.cbor.hex").exists());
assert!(artifacts.join("scripts/demo_gate.flat.hex").exists());
fs::remove_dir_all(root).expect("remove temp project");
}
#[test]
fn preview_artifacts_reject_sanitized_file_name_collisions() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-preview-collision-{unique}"));
let artifacts = root.join("artifacts");
let output = root.join("blueprint.preview.json");
fs::create_dir_all(&root).expect("create project dir");
fs::write(
root.join("foo.aeri"),
"module foo;\n\nvalidator bar_baz(datum: Data, redeemer: ByteArray, ctx: Tx) { true }\n",
)
.expect("write first source");
fs::write(
root.join("foo_bar.aeri"),
"module foo_bar;\n\nvalidator baz(datum: Data, redeemer: ByteArray, ctx: Tx) { true }\n",
)
.expect("write second source");
let error = run_cli(Cli {
command: Command::BuildProject {
root: root.clone(),
target: TargetArg::Preview,
output: Some(output.clone()),
artifacts: Some(artifacts.clone()),
},
})
.expect_err("colliding preview artifact names are rejected");
let rendered = error.to_string();
assert!(rendered.contains("artifact file name collision"));
assert!(rendered.contains("foo.bar_baz"));
assert!(rendered.contains("foo_bar.baz"));
assert!(!artifacts.exists());
assert!(!output.exists());
fs::remove_dir_all(root).expect("remove temp project");
}
#[test]
fn uplc_artifacts_reject_sanitized_file_name_collisions() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-uplc-collision-{unique}"));
let artifacts = root.join("artifacts");
let output = root.join("blueprint.uplc.json");
fs::create_dir_all(&root).expect("create project dir");
fs::write(
root.join("foo.aeri"),
"module foo;\n\nvalidator bar_baz(datum: Data, redeemer: ByteArray, ctx: Tx) { true }\n",
)
.expect("write first source");
fs::write(
root.join("foo_bar.aeri"),
"module foo_bar;\n\nvalidator baz(datum: Data, redeemer: ByteArray, ctx: Tx) { true }\n",
)
.expect("write second source");
let error = run_cli(Cli {
command: Command::BuildProject {
root: root.clone(),
target: TargetArg::Uplc,
output: Some(output.clone()),
artifacts: Some(artifacts.clone()),
},
})
.expect_err("colliding UPLC artifact names are rejected");
let rendered = error.to_string();
assert!(rendered.contains("artifact file name collision"));
assert!(rendered.contains("foo.bar_baz"));
assert!(rendered.contains("foo_bar.baz"));
assert!(!artifacts.exists());
assert!(!output.exists());
fs::remove_dir_all(root).expect("remove temp project");
}
#[test]
fn uplc_artifacts_reject_unsupported_subset_without_writing_target_files() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-uplc-artifact-blocker-{unique}"));
let artifacts = root.join("artifacts");
fs::create_dir_all(&root).expect("create temp dir");
let input = root.join("contract.aeri");
fs::write(
&input,
"module demo;\n\nvalidator spend(redeemer: ByteArray, ctx: Tx) { tx_paid_to(ctx, redeemer, 1) }\n",
)
.expect("write unsupported source");
let error = run_cli(Cli {
command: Command::Build {
input,
target: TargetArg::Uplc,
output: None,
blueprint: false,
artifacts: Some(artifacts.clone()),
},
})
.expect_err("unsupported UPLC artifacts are rejected");
let rendered = error.to_string();
assert!(rendered.contains("cannot be emitted as UPLC/CBOR"));
assert!(rendered.contains("tx_paid_to"));
assert!(!artifacts.exists());
fs::remove_dir_all(root).expect("remove temp dir");
}
#[test]
fn uplc_project_artifacts_reject_unsupported_subset_without_writing_target_files() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-uplc-project-blocker-{unique}"));
let artifacts = root.join("artifacts");
fs::create_dir_all(&root).expect("create temp project");
fs::write(
root.join("contract.aeri"),
"module demo;\n\nvalidator spend(redeemer: ByteArray, ctx: Tx) { tx_paid_to(ctx, redeemer, 1) }\n",
)
.expect("write unsupported source");
let error = run_cli(Cli {
command: Command::BuildProject {
root: root.clone(),
target: TargetArg::Uplc,
output: None,
artifacts: Some(artifacts.clone()),
},
})
.expect_err("unsupported UPLC project artifacts are rejected");
let rendered = error.to_string();
assert!(rendered.contains("cannot be emitted as UPLC/CBOR"));
assert!(rendered.contains("tx_paid_to"));
assert!(!artifacts.exists());
fs::remove_dir_all(root).expect("remove temp project");
}
#[test]
fn uplc_verify_rejects_cardano_cli_hash_mismatches() {
let source = "module demo;\n\nvalidator gate(datum: Data, redeemer: ByteArray, ctx: Tx) { redeemer == #01 }\n";
let compiled = compile_source("demo.aeri", source).expect("source compiles");
let (_, validators) =
build_uplc_blueprint("demo.aeri", &compiled).expect("supported UPLC blueprint builds");
let error = verify_cardano_cli_scripts_with("demo.aeri", &validators, "/bin/echo")
.expect_err("wrong cardano-cli hash is rejected");
let rendered = error.to_string();
assert!(rendered.contains("cardano-cli hash mismatch"));
assert!(rendered.contains("demo.gate"));
}
#[test]
fn uplc_inspect_check_fails_on_readiness_blockers() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let source = std::env::temp_dir().join(format!("aeri-uplc-inspect-{unique}.aeri"));
fs::write(
&source,
"module demo;\n\nvalidator spend(redeemer: ByteArray, ctx: Tx) { tx_paid_to(ctx, redeemer, 1) }\n",
)
.expect("write inspect source");
let error = run_cli(Cli {
command: Command::Inspect {
input: source.clone(),
target: TargetArg::Uplc,
json: true,
ir: false,
check: true,
check_core: false,
},
})
.expect_err("uplc inspect check fails on blockers");
let rendered = error.to_string();
assert!(rendered.contains("target 'uplc' has inspection blocker(s)"));
fs::remove_file(source).expect("remove inspect source");
}
#[test]
fn uplc_inspect_check_core_passes_for_supported_subset() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let source = std::env::temp_dir().join(format!("aeri-uplc-core-inspect-{unique}.aeri"));
fs::write(
&source,
"module demo;\n\nvalidator gate(datum: Data, redeemer: ByteArray, ctx: Tx) { require redeemer == #01; true }\n",
)
.expect("write inspect source");
run_cli(Cli {
command: Command::Inspect {
input: source.clone(),
target: TargetArg::Uplc,
json: true,
ir: true,
check: false,
check_core: true,
},
})
.expect("uplc inspect check-core passes for supported subset");
fs::remove_file(source).expect("remove inspect source");
}
#[test]
fn uplc_inspect_project_check_fails_on_readiness_blockers() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-uplc-inspect-project-{unique}"));
fs::create_dir_all(&root).expect("create inspect project");
fs::write(
root.join("main.aeri"),
"module demo;\n\nvalidator spend(redeemer: ByteArray, ctx: Tx) { tx_paid_to(ctx, redeemer, 1) }\n",
)
.expect("write inspect project source");
let error = run_cli(Cli {
command: Command::InspectProject {
root: root.clone(),
target: TargetArg::Uplc,
json: true,
ir: false,
check: true,
check_core: false,
},
})
.expect_err("uplc inspect-project check fails on blockers");
let rendered = error.to_string();
assert!(rendered.contains("target 'uplc' has project inspection blocker(s)"));
fs::remove_dir_all(root).expect("remove inspect project");
}
#[test]
fn project_files_ignore_generated_directories() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-project-files-{unique}"));
fs::create_dir_all(root.join("validators")).expect("create validators dir");
fs::create_dir_all(root.join("build")).expect("create build dir");
fs::create_dir_all(root.join("target")).expect("create target dir");
fs::create_dir_all(root.join("node_modules/pkg")).expect("create node_modules dir");
fs::write(
root.join("validators/main.aeri"),
"validator ok(r: ByteArray, ctx: Tx) { true }",
)
.expect("write source");
fs::write(root.join("build/generated.aeri"), "not valid aeri")
.expect("write generated source");
fs::write(root.join("target/generated.aeri"), "not valid aeri")
.expect("write target source");
fs::write(
root.join("node_modules/pkg/generated.aeri"),
"not valid aeri",
)
.expect("write vendored source");
let files = project_files(&root).expect("project files are discovered");
assert_eq!(files, vec![root.join("validators/main.aeri")]);
fs::remove_dir_all(root).expect("remove temp project");
}
#[test]
fn create_project_generates_verifiable_project() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-new-project-{unique}"));
create_project("fresh-contract", &root).expect("project is created");
let (files, compiled) = compile_project(&root).expect("project compiles");
let (_passed, failed) = run_tests_for_files(&files).expect("project tests run");
let warnings = lint_files(&files).expect("project lints");
let readme = fs::read_to_string(root.join("README.md")).expect("read project README");
assert_eq!(files.len(), 1);
assert_eq!(compiled.len(), 1);
assert_eq!(compiled[0].validator_count, 1);
assert_eq!(compiled[0].test_count, 1);
assert_eq!(failed, 0);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
assert!(readme.contains("aeri verify-project . --target preview"));
assert!(readme.contains("aeri inspect validators/main.aeri --target preview --ir"));
assert!(readme.contains("aeri inspect-project . --target uplc --json"));
assert!(readme.contains("aeri build-project . --target preview --artifacts build"));
fs::remove_dir_all(root).expect("remove temp project");
}
#[test]
fn compile_project_rejects_duplicate_validator_titles() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-duplicate-validator-{unique}"));
fs::create_dir_all(&root).expect("create project dir");
fs::write(
root.join("one.aeri"),
"module duplicate;\n\nvalidator spend(redeemer: ByteArray, ctx: Tx) {\n true\n}\n",
)
.expect("write first module");
fs::write(
root.join("two.aeri"),
"module duplicate;\n\nvalidator spend(redeemer: ByteArray, ctx: Tx) {\n true\n}\n",
)
.expect("write second module");
let error = compile_project(&root).expect_err("duplicate validator title is rejected");
let rendered = error.to_string();
assert!(rendered.contains("duplicate validator title 'duplicate.spend'"));
fs::remove_dir_all(root).expect("remove temp project");
}
#[test]
fn compile_project_rejects_conflicting_type_definitions() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-conflicting-definition-{unique}"));
fs::create_dir_all(&root).expect("create project dir");
fs::write(root.join("one.aeri"), "type Action {\n Spend\n}\n")
.expect("write first module");
fs::write(root.join("two.aeri"), "type Action {\n Close\n}\n")
.expect("write second module");
let error = compile_project(&root).expect_err("conflicting definition is rejected");
let rendered = error.to_string();
assert!(rendered.contains("conflicting blueprint definition 'Action'"));
fs::remove_dir_all(root).expect("remove temp project");
}
#[test]
fn artifacts_use_text_ir_extension() {
let unique = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock is after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!("aeri-artifacts-extension-{unique}"));
let compiled = compile_source(
"policy.aeri",
"module policy;\n\nvalidator spend(redeemer: ByteArray, ctx: Tx) {\n true\n}\n",
)
.expect("source compiles");
write_artifacts(&root, Target::Preview, &compiled.blueprint, &[])
.expect("artifacts are written");
assert!(root.join(PREVIEW_BLUEPRINT_FILE).exists());
let script_path = root.join(format!("scripts/policy_spend.{PREVIEW_SCRIPT_EXTENSION}"));
assert!(script_path.exists());
assert!(!root.join("scripts/policy_spend.uplc").exists());
let blueprint = fs::read_to_string(root.join(PREVIEW_BLUEPRINT_FILE))
.expect("read preview blueprint artifact");
let script = fs::read_to_string(script_path).expect("read script artifact");
assert!(blueprint.contains("\"cardanoDeployable\": false"));
assert_eq!(script, compiled.blueprint.validators[0].compiled_code);
fs::remove_dir_all(root).expect("remove temp artifacts");
}
}