1use crate::config::DevKitConfig;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub const WASM_BUILD_TARGET: &str = "wasm32v1-none";
8
9pub const STELLAR_CLI_MIN_VERSION: (u64, u64) = (25, 2);
20
21#[derive(Debug)]
22pub enum BuildError {
23 MissingConfig,
24 PathNotFound(String),
25 CargoFailed {
26 path: String,
27 stderr: String,
28 },
29 ArtifactNotFound(String),
30 InvalidProject(String),
31 StellarCliMissing,
34 StellarCliUnsupported {
36 version: String,
37 },
38}
39
40impl fmt::Display for BuildError {
41 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42 match self {
43 BuildError::MissingConfig => write!(f, "No [contracts] configured in .sdkt.toml"),
44 BuildError::PathNotFound(path) => write!(f, "Contract path does not exist: {}", path),
45 BuildError::CargoFailed { path, stderr } => {
46 write!(f, "Cargo build failed in {}:\n{}", path, stderr)
47 }
48 BuildError::ArtifactNotFound(path) => {
49 write!(f, "Expected WASM artifact not found at: {}", path)
50 }
51 BuildError::InvalidProject(msg) => {
52 write!(f, "Invalid project dependency graph: {}", msg)
53 }
54 BuildError::StellarCliMissing => write!(
55 f,
56 "This contract requires `stellar contract build` (soroban-sdk v28 or later \
57 builds its spec with the build system), but the `stellar` CLI was not found. \
58 Install stellar-cli v{}.{} (https://developers.stellar.org/docs/building \
59 or `cargo install --locked stellar-cli`) and retry.",
60 STELLAR_CLI_MIN_VERSION.0, STELLAR_CLI_MIN_VERSION.1
61 ),
62 BuildError::StellarCliUnsupported { version } => write!(
63 f,
64 "Installed stellar-cli {} is too old for soroban-sdk v28 contract builds. \
65 Spec shaking v2 requires `stellar contract build` from stellar-cli v{}.{} \
66 (found {}).",
67 version, STELLAR_CLI_MIN_VERSION.0, STELLAR_CLI_MIN_VERSION.1, version
68 ),
69 }
70 }
71}
72
73impl std::error::Error for BuildError {}
74
75#[derive(Debug, PartialEq)]
77pub struct BuildResult {
78 pub alias: String,
79 pub path: String,
80 pub wasm_artifact: PathBuf,
81}
82
83fn parse_leading_version(req: &str) -> Option<(u64, u64)> {
86 let req = req
87 .trim()
88 .trim_start_matches(['=', '^', '~', '>', '<', 'v', ' ']);
89 let mut parts = req.split(|c: char| !c.is_ascii_digit());
90 let major = parts.next()?.parse().ok()?;
91 let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
92 Some((major, minor))
93}
94
95fn declared_version(line: &str) -> Option<(u64, u64)> {
101 let quoted = |from: &str| -> Option<(u64, u64)> {
102 let q1 = from.find('"')?;
103 let rest = &from[q1 + 1..];
104 let q2 = rest.find('"')?;
105 parse_leading_version(&rest[..q2])
106 };
107 if let Some(vpos) = line.find("version") {
108 if let Some(v) = quoted(&line[vpos + "version".len()..]) {
109 return Some(v);
110 }
111 }
112 if let Some(eq) = line.find('=') {
114 return quoted(&line[eq + 1..]);
115 }
116 None
117}
118
119fn requires_sdk_28(manifest: &str) -> bool {
127 let mut table_dep: Option<String> = None;
129 let mut in_deps = false;
130 for line in manifest.lines() {
131 let t = line.trim();
132 if t.starts_with('[') {
133 in_deps = t == "[dependencies]" || t.starts_with("[dependencies.");
134 table_dep = if in_deps {
135 t.strip_prefix("[dependencies.")
136 .map(|name| name.trim_end_matches(']').to_string())
137 } else {
138 None
139 };
140 continue;
141 }
142 if !in_deps {
143 continue;
144 }
145 if line.contains("soroban-sdk") || table_dep.as_deref() == Some("soroban-sdk") {
146 if let Some((major, _)) = declared_version(line) {
147 return major >= 28;
148 }
149 }
150 }
151 false
152}
153
154pub(crate) fn requires_v28_build_system(path: &Path) -> bool {
160 let Ok(manifest) = std::fs::read_to_string(path.join("Cargo.toml")) else {
161 return false;
162 };
163 requires_sdk_28(&manifest)
164}
165
166fn stellar_cli_version() -> Option<(u64, u64)> {
169 let output = Command::new("stellar").arg("--version").output().ok()?;
170 if !output.status.success() {
171 return None;
172 }
173 let line = String::from_utf8_lossy(&output.stdout);
174 parse_leading_version(line.split_whitespace().nth(1)?)
175}
176
177pub fn build_workspace(config: &DevKitConfig) -> Result<Vec<BuildResult>, BuildError> {
187 if config.contracts.is_empty() {
188 return Err(BuildError::MissingConfig);
189 }
190
191 let ordered = crate::project::resolve_deploy_order(config)
194 .map_err(|e| BuildError::InvalidProject(e.to_string()))?;
195
196 let mut results = Vec::new();
197
198 for alias in &ordered {
199 let contract_cfg = config
200 .contracts
201 .get(alias)
202 .expect("alias from resolved order");
203 let path = Path::new(&contract_cfg.path);
204
205 if !path.exists() || !path.is_dir() {
206 return Err(BuildError::PathNotFound(contract_cfg.path.clone()));
207 }
208
209 let report_failure = |stderr: &[u8], label: &str| {
217 BuildError::CargoFailed {
218 path: contract_cfg.path.clone(),
219 stderr: format!(
220 "{label} for target {WASM_BUILD_TARGET} failed. If this target is not installed, run `rustup target add {WASM_BUILD_TARGET}`.\n{}",
221 String::from_utf8_lossy(stderr)
222 ),
223 }
224 };
225
226 if requires_v28_build_system(path) {
227 let (major, minor) = stellar_cli_version().ok_or(BuildError::StellarCliMissing)?;
228 if (major, minor) < STELLAR_CLI_MIN_VERSION {
229 return Err(BuildError::StellarCliUnsupported {
230 version: format!("{major}.{minor}"),
231 });
232 }
233 let output = Command::new("stellar")
234 .arg("contract")
235 .arg("build")
236 .current_dir(path)
237 .output()
238 .map_err(|e| BuildError::CargoFailed {
239 path: contract_cfg.path.clone(),
240 stderr: e.to_string(),
241 })?;
242 if !output.status.success() {
243 return Err(report_failure(&output.stderr, "`stellar contract build`"));
244 }
245 } else {
246 let output = Command::new("cargo")
248 .arg("build")
249 .arg("--target")
250 .arg(WASM_BUILD_TARGET)
251 .arg("--release")
252 .current_dir(path)
253 .output()
254 .map_err(|e| BuildError::CargoFailed {
255 path: contract_cfg.path.clone(),
256 stderr: e.to_string(),
257 })?;
258 if !output.status.success() {
259 return Err(report_failure(&output.stderr, "Build"));
260 }
261 }
262
263 let target_dir = path.join("target").join(WASM_BUILD_TARGET).join("release");
269
270 let mut found_wasm = None;
271 if target_dir.exists() {
272 if let Ok(entries) = std::fs::read_dir(&target_dir) {
273 for entry in entries.flatten() {
274 let p = entry.path();
275 if p.extension().is_some_and(|ext| ext == "wasm") {
276 found_wasm = Some(p);
277 break;
278 }
279 }
280 }
281 }
282
283 let wasm_artifact = found_wasm
284 .ok_or_else(|| BuildError::ArtifactNotFound(target_dir.display().to_string()))?;
285
286 results.push(BuildResult {
287 alias: alias.clone(),
288 path: contract_cfg.path.clone(),
289 wasm_artifact,
290 });
291 }
292
293 if let Ok(lock) = crate::lock::generate_lock(Path::new("."), config) {
298 match crate::lock::write_lock(Path::new("."), &lock) {
299 Ok(path) => {
300 if let Ok(toml) = crate::lock::lock_to_toml(&lock) {
301 eprintln!("✓ Wrote {}", path.display());
306 eprintln!("{}", toml);
307 }
308 }
309 Err(e) => eprintln!("Warning: could not write sdkt.lock: {}", e),
310 }
311 }
312
313 Ok(results)
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use crate::config::ContractConfig;
320 use std::collections::HashMap;
321
322 #[test]
323 fn test_build_empty_config() {
324 let config = DevKitConfig::default();
325 let res = build_workspace(&config);
326 assert!(matches!(res, Err(BuildError::MissingConfig)));
327 }
328
329 #[test]
330 fn test_build_missing_path() {
331 let mut config = DevKitConfig::default();
332 let mut contracts = HashMap::new();
333 contracts.insert(
334 "token".to_string(),
335 ContractConfig {
336 path: "does_not_exist_xyz".to_string(),
337 deploy_after: vec![],
338 depends_on: vec![],
339 },
340 );
341 config.contracts = contracts;
342
343 let res = build_workspace(&config);
344 match res {
345 Err(BuildError::PathNotFound(p)) => assert_eq!(p, "does_not_exist_xyz"),
346 _ => panic!("Expected PathNotFound"),
347 }
348 }
349
350 #[test]
353 fn parses_leading_version_from_requirement_styles() {
354 assert_eq!(parse_leading_version("28.0.0"), Some((28, 0)));
355 assert_eq!(parse_leading_version("=27.0.6"), Some((27, 0)));
356 assert_eq!(parse_leading_version("^28"), Some((28, 0)));
357 assert_eq!(parse_leading_version("v25.2.1"), Some((25, 2)));
358 assert_eq!(parse_leading_version(">=28.0.0"), Some((28, 0)));
359 assert_eq!(parse_leading_version("no-digits-here"), None);
360 }
361
362 #[test]
363 fn detects_sdk_28_requirement_from_manifest() {
364 assert!(requires_sdk_28(
366 "[dependencies]\nsoroban-sdk = { version = \"28.0.0\" }\n"
367 ));
368 assert!(!requires_sdk_28(
370 "[dependencies]\nsoroban-sdk = \"=27.0.6\"\n\n[dev-dependencies]\nsoroban-sdk = { version = \"=27.0.6\", features = [\"testutils\"] }\n"
371 ));
372 assert!(requires_sdk_28(
374 "[dependencies.soroban-sdk]\nversion = \"28.1.0\"\n"
375 ));
376 assert!(!requires_sdk_28(
378 "[dependencies]\nsoroban-sdk.workspace = true\n"
379 ));
380 assert!(!requires_sdk_28("[dependencies]\nserde = \"1\"\n"));
381 }
382
383 #[test]
384 fn reports_actionable_error_when_stellar_cli_missing() {
385 let msg = BuildError::StellarCliMissing.to_string();
386 assert!(msg.contains("stellar contract build"));
387 assert!(msg.contains("25.2"), "must name the minimum version: {msg}");
388 }
389
390 #[test]
391 fn reports_actionable_error_when_stellar_cli_too_old() {
392 let msg = BuildError::StellarCliUnsupported {
393 version: "24.0.0".to_string(),
394 }
395 .to_string();
396 assert!(msg.contains("24.0.0"));
397 assert!(msg.contains("25.2"), "must name the minimum version: {msg}");
398 }
399}