use crate::config::DevKitConfig;
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
pub const WASM_BUILD_TARGET: &str = "wasm32v1-none";
pub const STELLAR_CLI_MIN_VERSION: (u64, u64) = (25, 2);
#[derive(Debug)]
pub enum BuildError {
MissingConfig,
PathNotFound(String),
CargoFailed {
path: String,
stderr: String,
},
ArtifactNotFound(String),
InvalidProject(String),
StellarCliMissing,
StellarCliUnsupported {
version: String,
},
}
impl fmt::Display for BuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BuildError::MissingConfig => write!(f, "No [contracts] configured in .sdkt.toml"),
BuildError::PathNotFound(path) => write!(f, "Contract path does not exist: {}", path),
BuildError::CargoFailed { path, stderr } => {
write!(f, "Cargo build failed in {}:\n{}", path, stderr)
}
BuildError::ArtifactNotFound(path) => {
write!(f, "Expected WASM artifact not found at: {}", path)
}
BuildError::InvalidProject(msg) => {
write!(f, "Invalid project dependency graph: {}", msg)
}
BuildError::StellarCliMissing => write!(
f,
"This contract requires `stellar contract build` (soroban-sdk v28 or later \
builds its spec with the build system), but the `stellar` CLI was not found. \
Install stellar-cli v{}.{} (https://developers.stellar.org/docs/building \
or `cargo install --locked stellar-cli`) and retry.",
STELLAR_CLI_MIN_VERSION.0, STELLAR_CLI_MIN_VERSION.1
),
BuildError::StellarCliUnsupported { version } => write!(
f,
"Installed stellar-cli {} is too old for soroban-sdk v28 contract builds. \
Spec shaking v2 requires `stellar contract build` from stellar-cli v{}.{} \
(found {}).",
version, STELLAR_CLI_MIN_VERSION.0, STELLAR_CLI_MIN_VERSION.1, version
),
}
}
}
impl std::error::Error for BuildError {}
#[derive(Debug, PartialEq)]
pub struct BuildResult {
pub alias: String,
pub path: String,
pub wasm_artifact: PathBuf,
}
fn parse_leading_version(req: &str) -> Option<(u64, u64)> {
let req = req
.trim()
.trim_start_matches(['=', '^', '~', '>', '<', 'v', ' ']);
let mut parts = req.split(|c: char| !c.is_ascii_digit());
let major = parts.next()?.parse().ok()?;
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
Some((major, minor))
}
fn declared_version(line: &str) -> Option<(u64, u64)> {
let quoted = |from: &str| -> Option<(u64, u64)> {
let q1 = from.find('"')?;
let rest = &from[q1 + 1..];
let q2 = rest.find('"')?;
parse_leading_version(&rest[..q2])
};
if let Some(vpos) = line.find("version") {
if let Some(v) = quoted(&line[vpos + "version".len()..]) {
return Some(v);
}
}
if let Some(eq) = line.find('=') {
return quoted(&line[eq + 1..]);
}
None
}
fn requires_sdk_28(manifest: &str) -> bool {
let mut table_dep: Option<String> = None;
let mut in_deps = false;
for line in manifest.lines() {
let t = line.trim();
if t.starts_with('[') {
in_deps = t == "[dependencies]" || t.starts_with("[dependencies.");
table_dep = if in_deps {
t.strip_prefix("[dependencies.")
.map(|name| name.trim_end_matches(']').to_string())
} else {
None
};
continue;
}
if !in_deps {
continue;
}
if line.contains("soroban-sdk") || table_dep.as_deref() == Some("soroban-sdk") {
if let Some((major, _)) = declared_version(line) {
return major >= 28;
}
}
}
false
}
pub(crate) fn requires_v28_build_system(path: &Path) -> bool {
let Ok(manifest) = std::fs::read_to_string(path.join("Cargo.toml")) else {
return false;
};
requires_sdk_28(&manifest)
}
fn stellar_cli_version() -> Option<(u64, u64)> {
let output = Command::new("stellar").arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let line = String::from_utf8_lossy(&output.stdout);
parse_leading_version(line.split_whitespace().nth(1)?)
}
pub fn build_workspace(config: &DevKitConfig) -> Result<Vec<BuildResult>, BuildError> {
if config.contracts.is_empty() {
return Err(BuildError::MissingConfig);
}
let ordered = crate::project::resolve_deploy_order(config)
.map_err(|e| BuildError::InvalidProject(e.to_string()))?;
let mut results = Vec::new();
for alias in &ordered {
let contract_cfg = config
.contracts
.get(alias)
.expect("alias from resolved order");
let path = Path::new(&contract_cfg.path);
if !path.exists() || !path.is_dir() {
return Err(BuildError::PathNotFound(contract_cfg.path.clone()));
}
let report_failure = |stderr: &[u8], label: &str| {
BuildError::CargoFailed {
path: contract_cfg.path.clone(),
stderr: format!(
"{label} for target {WASM_BUILD_TARGET} failed. If this target is not installed, run `rustup target add {WASM_BUILD_TARGET}`.\n{}",
String::from_utf8_lossy(stderr)
),
}
};
if requires_v28_build_system(path) {
let (major, minor) = stellar_cli_version().ok_or(BuildError::StellarCliMissing)?;
if (major, minor) < STELLAR_CLI_MIN_VERSION {
return Err(BuildError::StellarCliUnsupported {
version: format!("{major}.{minor}"),
});
}
let output = Command::new("stellar")
.arg("contract")
.arg("build")
.current_dir(path)
.output()
.map_err(|e| BuildError::CargoFailed {
path: contract_cfg.path.clone(),
stderr: e.to_string(),
})?;
if !output.status.success() {
return Err(report_failure(&output.stderr, "`stellar contract build`"));
}
} else {
let output = Command::new("cargo")
.arg("build")
.arg("--target")
.arg(WASM_BUILD_TARGET)
.arg("--release")
.current_dir(path)
.output()
.map_err(|e| BuildError::CargoFailed {
path: contract_cfg.path.clone(),
stderr: e.to_string(),
})?;
if !output.status.success() {
return Err(report_failure(&output.stderr, "Build"));
}
}
let target_dir = path.join("target").join(WASM_BUILD_TARGET).join("release");
let mut found_wasm = None;
if target_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&target_dir) {
for entry in entries.flatten() {
let p = entry.path();
if p.extension().is_some_and(|ext| ext == "wasm") {
found_wasm = Some(p);
break;
}
}
}
}
let wasm_artifact = found_wasm
.ok_or_else(|| BuildError::ArtifactNotFound(target_dir.display().to_string()))?;
results.push(BuildResult {
alias: alias.clone(),
path: contract_cfg.path.clone(),
wasm_artifact,
});
}
if let Ok(lock) = crate::lock::generate_lock(Path::new("."), config) {
match crate::lock::write_lock(Path::new("."), &lock) {
Ok(path) => {
if let Ok(toml) = crate::lock::lock_to_toml(&lock) {
eprintln!("✓ Wrote {}", path.display());
eprintln!("{}", toml);
}
}
Err(e) => eprintln!("Warning: could not write sdkt.lock: {}", e),
}
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ContractConfig;
use std::collections::HashMap;
#[test]
fn test_build_empty_config() {
let config = DevKitConfig::default();
let res = build_workspace(&config);
assert!(matches!(res, Err(BuildError::MissingConfig)));
}
#[test]
fn test_build_missing_path() {
let mut config = DevKitConfig::default();
let mut contracts = HashMap::new();
contracts.insert(
"token".to_string(),
ContractConfig {
path: "does_not_exist_xyz".to_string(),
deploy_after: vec![],
depends_on: vec![],
},
);
config.contracts = contracts;
let res = build_workspace(&config);
match res {
Err(BuildError::PathNotFound(p)) => assert_eq!(p, "does_not_exist_xyz"),
_ => panic!("Expected PathNotFound"),
}
}
#[test]
fn parses_leading_version_from_requirement_styles() {
assert_eq!(parse_leading_version("28.0.0"), Some((28, 0)));
assert_eq!(parse_leading_version("=27.0.6"), Some((27, 0)));
assert_eq!(parse_leading_version("^28"), Some((28, 0)));
assert_eq!(parse_leading_version("v25.2.1"), Some((25, 2)));
assert_eq!(parse_leading_version(">=28.0.0"), Some((28, 0)));
assert_eq!(parse_leading_version("no-digits-here"), None);
}
#[test]
fn detects_sdk_28_requirement_from_manifest() {
assert!(requires_sdk_28(
"[dependencies]\nsoroban-sdk = { version = \"28.0.0\" }\n"
));
assert!(!requires_sdk_28(
"[dependencies]\nsoroban-sdk = \"=27.0.6\"\n\n[dev-dependencies]\nsoroban-sdk = { version = \"=27.0.6\", features = [\"testutils\"] }\n"
));
assert!(requires_sdk_28(
"[dependencies.soroban-sdk]\nversion = \"28.1.0\"\n"
));
assert!(!requires_sdk_28(
"[dependencies]\nsoroban-sdk.workspace = true\n"
));
assert!(!requires_sdk_28("[dependencies]\nserde = \"1\"\n"));
}
#[test]
fn reports_actionable_error_when_stellar_cli_missing() {
let msg = BuildError::StellarCliMissing.to_string();
assert!(msg.contains("stellar contract build"));
assert!(msg.contains("25.2"), "must name the minimum version: {msg}");
}
#[test]
fn reports_actionable_error_when_stellar_cli_too_old() {
let msg = BuildError::StellarCliUnsupported {
version: "24.0.0".to_string(),
}
.to_string();
assert!(msg.contains("24.0.0"));
assert!(msg.contains("25.2"), "must name the minimum version: {msg}");
}
}