use std::fs;
use std::path::{Path, PathBuf};
use canton_lf::{Dar, DarError, DecodeError, decode_main_package, package_version};
use crate::{CodegenError, SkippedType, generate_crate};
const CARGO_TOML_MARKER: &str = "# generated by dpm-codegen-rust — do not edit by hand";
const LIB_RS_MARKER: &str = "Typed Rust bindings generated from a Daml archive";
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum GenerateError {
#[error("reading {path}")]
ReadDar {
path: PathBuf,
#[source]
source: DarError,
},
#[error("decoding {path}")]
DecodeDar {
path: PathBuf,
#[source]
source: DecodeError,
},
#[error("{path} contains no Daml-LF packages — is it a DAR built with `daml build`?")]
EmptyDar {
path: PathBuf,
},
#[error(
"`{name}` is not a valid Cargo package name (ASCII letters, digits, `-`/`_`; \
must not start with a digit)"
)]
InvalidCrateName {
name: String,
},
#[error(
"the DAR declares the package version `{version}`, which is not a semantic \
version Cargo will accept (`MAJOR.MINOR.PATCH`, optionally `-pre` and \
`+build`). Rebuild the DAR with a plain version"
)]
InvalidCrateVersion {
version: String,
},
#[error(
"`{requirement}` is not a Cargo version requirement (e.g. `0.2`, `^0.2`, \
`>=0.2, <0.4`)"
)]
InvalidRuntimeRequirement {
requirement: String,
},
#[error(
"{path} already exists and was not generated by this tool; \
choose an empty output directory or force the overwrite"
)]
WouldClobber {
path: PathBuf,
},
#[error(transparent)]
Codegen(#[from] CodegenError),
#[error("writing {path}")]
Write {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum Runtime {
Version(String),
Path(PathBuf),
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Options {
dar: PathBuf,
out: PathBuf,
crate_name: Option<String>,
runtime: Runtime,
force: bool,
external: crate::lower::ExternalPackages,
}
impl Options {
#[must_use]
pub fn new(dar: impl Into<PathBuf>, out: impl Into<PathBuf>) -> Self {
Self {
dar: dar.into(),
out: out.into(),
crate_name: None,
runtime: Runtime::Version(DEFAULT_RUNTIME_REQ.to_string()),
force: false,
external: crate::lower::ExternalPackages::new(),
}
}
#[must_use]
pub fn with_crate_name(mut self, name: impl Into<String>) -> Self {
self.crate_name = Some(name.into());
self
}
#[must_use]
pub fn with_runtime(mut self, runtime: Runtime) -> Self {
self.runtime = runtime;
self
}
#[must_use]
pub fn with_external_package(
mut self,
package: impl Into<String>,
crate_name: impl Into<String>,
) -> Self {
self.external = self.external.with(package, crate_name);
self
}
#[must_use]
pub fn with_force(mut self, force: bool) -> Self {
self.force = force;
self
}
#[must_use]
pub fn crate_name(&self) -> String {
self.crate_name
.clone()
.unwrap_or_else(|| default_crate_name(&self.dar))
}
}
const DEFAULT_RUNTIME_REQ: &str = concat!(
env!("CARGO_PKG_VERSION_MAJOR"),
".",
env!("CARGO_PKG_VERSION_MINOR")
);
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Stats {
pub packages: usize,
pub modules: usize,
pub items: usize,
pub bytes: usize,
pub skipped: Vec<SkippedType>,
}
pub fn generate(opts: &Options) -> Result<Stats, GenerateError> {
let crate_name = opts.crate_name();
validate_crate_name(&crate_name)?;
let dar = Dar::open(&opts.dar).map_err(|source| GenerateError::ReadDar {
path: opts.dar.clone(),
source,
})?;
let (krate, skipped) =
crate::lower::lower_dar_with(&dar, &opts.external).map_err(|source| {
GenerateError::DecodeDar {
path: opts.dar.clone(),
source,
}
})?;
if krate.packages.is_empty() {
return Err(GenerateError::EmptyDar {
path: opts.dar.clone(),
});
}
let source = generate_crate(&krate)?;
let modules = krate.packages.iter().map(|p| p.modules.len()).sum();
let items = krate
.packages
.iter()
.flat_map(|p| &p.modules)
.map(|m| m.module.data_types.len() + m.module.templates.len() + m.module.interfaces.len())
.sum();
let cargo_toml_path = opts.out.join("Cargo.toml");
let lib_rs_path = opts.out.join("src").join("lib.rs");
if !opts.force {
refuse_foreign(&cargo_toml_path, CARGO_TOML_MARKER)?;
refuse_foreign(&lib_rs_path, LIB_RS_MARKER)?;
}
let src_dir = opts.out.join("src");
fs::create_dir_all(&src_dir).map_err(|source| GenerateError::Write {
path: src_dir,
source,
})?;
let version = crate_version(&dar);
validate_crate_version(&version)?;
if let Runtime::Version(requirement) = &opts.runtime {
validate_runtime_requirement(requirement)?;
}
let manifest = cargo_toml(opts, &crate_name, &version);
fs::write(&cargo_toml_path, manifest).map_err(|source| GenerateError::Write {
path: cargo_toml_path,
source,
})?;
fs::write(&lib_rs_path, &source).map_err(|source| GenerateError::Write {
path: lib_rs_path,
source,
})?;
Ok(Stats {
packages: krate.packages.len(),
modules,
items,
bytes: source.len(),
skipped,
})
}
fn refuse_foreign(path: &Path, marker: &str) -> Result<(), GenerateError> {
match fs::read_to_string(path) {
Ok(existing) => {
let head: String = existing.lines().take(5).collect::<Vec<_>>().join("\n");
if head.contains(marker) {
Ok(()) } else {
Err(GenerateError::WouldClobber {
path: path.to_path_buf(),
})
}
}
Err(_) => Ok(()), }
}
fn crate_version(dar: &Dar) -> String {
decode_main_package(dar)
.ok()
.and_then(|(package, _)| package_version(&package).map(str::to_string))
.map_or_else(|| "0.0.0".to_string(), |version| pad_to_semver(&version))
}
fn pad_to_semver(version: &str) -> String {
let parts: Vec<&str> = version.split('.').collect();
let numeric = parts.len() < 3
&& !parts.is_empty()
&& parts
.iter()
.all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
if numeric {
let mut padded = parts.join(".");
for _ in parts.len()..3 {
padded.push_str(".0");
}
padded
} else {
version.to_string()
}
}
fn cargo_toml(opts: &Options, crate_name: &str, version: &str) -> String {
let dependency = match &opts.runtime {
Runtime::Version(requirement) => {
format!("canton-daml = \"{}\"", toml_escape(requirement))
}
Runtime::Path(path) => {
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.clone());
format!(
"canton-daml = {{ path = \"{}\" }}",
toml_escape(&absolute.display().to_string())
)
}
};
let externals = opts
.external
.crate_names()
.iter()
.fold(String::new(), |mut out, name| {
use std::fmt::Write as _;
let _ = writeln!(out, "{} = \"*\" # pin this", name.replace('_', "-"));
out
});
format!(
"{CARGO_TOML_MARKER}\n\
[package]\n\
name = \"{crate_name}\"\n\
version = \"{version}\"\n\
edition = \"2021\"\n\
description = \"Typed Rust bindings generated from a Daml archive (DAR).\"\n\
# Fill these in before publishing the generated crate.\n\
# license = \"Apache-2.0\"\n\
# repository = \"\"\n\
\n\
[dependencies]\n{dependency}\n{externals}"
)
}
fn toml_escape(raw: &str) -> String {
raw.chars()
.fold(String::with_capacity(raw.len()), |mut out, c| {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
use std::fmt::Write as _;
let _ = write!(out, "\\u{:04X}", c as u32);
}
c => out.push(c),
}
out
})
}
fn validate_crate_version(version: &str) -> Result<(), GenerateError> {
semver::Version::parse(version)
.map(|_| ())
.map_err(|_| GenerateError::InvalidCrateVersion {
version: version.to_string(),
})
}
fn validate_runtime_requirement(requirement: &str) -> Result<(), GenerateError> {
semver::VersionReq::parse(requirement)
.map(|_| ())
.map_err(|_| GenerateError::InvalidRuntimeRequirement {
requirement: requirement.to_string(),
})
}
fn validate_crate_name(name: &str) -> Result<(), GenerateError> {
let valid = !name.is_empty()
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if valid {
Ok(())
} else {
Err(GenerateError::InvalidCrateName {
name: name.to_string(),
})
}
}
#[must_use]
pub fn default_crate_name(dar: &Path) -> String {
let stem = dar
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("bindings");
let stem = strip_trailing_version(stem);
let name: String = stem
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'-'
}
})
.collect();
let name = name.trim_matches('-').to_string();
if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
format!("bindings-{name}").trim_end_matches('-').to_string()
} else {
name
}
}
fn strip_trailing_version(stem: &str) -> &str {
match stem.rsplit_once('-') {
Some((head, tail))
if !tail.is_empty()
&& tail
.split('.')
.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) =>
{
head
}
_ => stem,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn default_crate_names_drop_the_dar_version_and_stay_valid() {
let cases = [
("splice-amulet-0.1.14.dar", "splice-amulet"),
("splice-amulet-0.1.15.dar", "splice-amulet"),
("quickstart-licensing-0.0.1.dar", "quickstart-licensing"),
("quickstart licensing.dar", "quickstart-licensing"),
("my-app-v2.dar", "my-app-v2"),
("0.1.14.dar", "bindings-0-1-14"),
];
for (input, expected) in cases {
let name = default_crate_name(Path::new(input));
assert_eq!(name, expected, "for {input}");
assert!(validate_crate_name(&name).is_ok(), "{name}");
}
}
#[test]
fn invalid_crate_names_are_rejected_with_a_typed_error() {
for bad in ["", "1abc", "has space", "naïve"] {
assert!(matches!(
validate_crate_name(bad),
Err(GenerateError::InvalidCrateName { .. })
));
}
assert!(validate_crate_name("fine-name_2").is_ok());
}
#[test]
fn cargo_toml_is_publishable_and_escapes_windows_paths() {
let options = Options::new("x.dar", "out")
.with_runtime(Runtime::Path(PathBuf::from(r"C:\Users\dev\canton-daml")));
let toml = cargo_toml(&options, "x", "0.0.0");
assert!(toml.contains(r"C:\\Users\\dev\\canton-daml"), "{toml}");
assert!(toml.starts_with(CARGO_TOML_MARKER), "{toml}");
assert!(toml.contains("description = "), "{toml}");
assert!(!toml.contains("[workspace]"), "{toml}");
}
#[test]
fn the_default_runtime_requirement_tracks_this_crate() {
assert_eq!(
DEFAULT_RUNTIME_REQ,
concat!(
env!("CARGO_PKG_VERSION_MAJOR"),
".",
env!("CARGO_PKG_VERSION_MINOR")
)
);
assert!(matches!(
Options::new("x.dar", "o").runtime,
Runtime::Version(_)
));
}
#[test]
fn the_lib_rs_marker_matches_what_the_generator_emits() {
let emitted = crate::generate_crate(&crate::ir::Crate::default()).unwrap();
assert!(
emitted.contains(LIB_RS_MARKER),
"generated header must carry the marker, got:\n{emitted}"
);
}
#[test]
fn refuses_to_overwrite_a_foreign_file() {
let dir = std::env::temp_dir().join(format!("codegen-clobber-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("Cargo.toml");
std::fs::write(&file, "[package]\nname = \"users-own-crate\"\n").unwrap();
assert!(matches!(
refuse_foreign(&file, CARGO_TOML_MARKER),
Err(GenerateError::WouldClobber { .. })
));
std::fs::write(&file, format!("{CARGO_TOML_MARKER}\n[package]\n")).unwrap();
assert!(refuse_foreign(&file, CARGO_TOML_MARKER).is_ok());
assert!(refuse_foreign(&dir.join("absent.toml"), CARGO_TOML_MARKER).is_ok());
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_package_version_cannot_inject_into_the_manifest() {
let hostile = "0.1.0\"\n[dependencies.evil]\ngit = \"https://example.invalid/repo";
let error = validate_crate_version(hostile).expect_err("must be refused");
assert!(
matches!(error, GenerateError::InvalidCrateVersion { .. }),
"{error}"
);
assert!(error.to_string().contains("0.1.0"), "{error}");
assert!(validate_crate_version("0.1.0\nx = 1").is_err());
assert!(validate_crate_version("").is_err());
assert!(validate_crate_version("2.0").is_err());
assert_eq!(pad_to_semver("2.0"), "2.0.0");
assert_eq!(pad_to_semver("1"), "1.0.0");
assert_eq!(
pad_to_semver("0.1.14"),
"0.1.14",
"a complete version is untouched"
);
assert_eq!(
pad_to_semver("hello"),
"hello",
"padding never rescues a non-version"
);
for good in [
"0.0.0",
"1.2.3",
"0.1.14",
"1.0.0-SNAPSHOT",
"1.0.0-rc.1+build.5",
] {
assert!(
validate_crate_version(good).is_ok(),
"{good} should be accepted"
);
}
}
#[test]
fn a_manifest_is_never_written_with_a_version_cargo_cannot_read() {
for bad in ["hello", "1..2", "1.0_bad", "", "1.0", "v1.0.0"] {
assert!(
validate_crate_version(bad).is_err(),
"`{bad}` is not a Cargo package version"
);
}
for good in [
"0.0.1",
"0.1.14",
"1.0.0",
"1.0.6",
"1.0.0-SNAPSHOT",
"1.0.0-rc.1+build.5",
] {
assert!(validate_crate_version(good).is_ok(), "{good}");
}
for good in [DEFAULT_RUNTIME_REQ, "0.2", "^0.2", ">=0.2, <0.4", "*"] {
assert!(validate_runtime_requirement(good).is_ok(), "{good}");
}
for bad in ["0..2", "hello", "", ">>0.2"] {
assert!(validate_runtime_requirement(bad).is_err(), "`{bad}`");
}
}
#[test]
fn a_runtime_requirement_cannot_inject_into_the_manifest() {
let hostile = "0.2\"\n[dependencies.evil]\ngit = \"https://example.invalid/repo";
assert!(
validate_runtime_requirement(hostile).is_err(),
"must be refused before it reaches the manifest"
);
let opts = Options::new("x.dar", "out").with_runtime(Runtime::Version(hostile.to_string()));
let manifest = cargo_toml(&opts, "bindings", "1.0.0");
assert!(
!manifest
.lines()
.any(|l| l.trim_start().starts_with('[') && l.contains("evil")),
"escaped output opened a table:\n{manifest}"
);
let dependency = manifest
.lines()
.find(|l| l.starts_with("canton-daml"))
.expect("the dependency line");
assert!(!dependency.contains('\n'));
}
}