Skip to main content

sdkt_core/
build.rs

1use crate::config::DevKitConfig;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6/// Default Soroban contract target supported by current soroban-sdk releases.
7pub const WASM_BUILD_TARGET: &str = "wasm32v1-none";
8
9/// Minimum `stellar-cli` for the canonical build path.
10///
11/// `soroban-sdk` v28 turns spec shaking v2 on unconditionally: the SDK emits a
12/// spec entry and a marker for every type, and the *build system* is expected to
13/// strip the entries unreachable from the contract boundary. Building with plain
14/// `cargo build` therefore exits 101 ("soroban-sdk requires stellar-cli
15/// v25.2.0+ to build a contract") on wasm targets, and bypassing that guard with
16/// the raw env var yields an **unstripped, oversized** spec — an artifact that is
17/// not what v28 defines. `stellar contract build` is that shaking build system;
18/// v25.2.0 is the first release that provides it.
19pub 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    /// The contract needs the canonical v28 shaking build system
32    /// (`stellar contract build`), but `stellar` is not installed.
33    StellarCliMissing,
34    /// `stellar` is installed but older than [`STELLAR_CLI_MIN_VERSION`].
35    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/// Result of building a single contract.
76#[derive(Debug, PartialEq)]
77pub struct BuildResult {
78    pub alias: String,
79    pub path: String,
80    pub wasm_artifact: PathBuf,
81}
82
83/// Parse the leading `major.minor` out of a semver requirement such as
84/// `"28.0.0"`, `"=27.0.6"` or `"^28"`. Returns `None` when no digits lead.
85fn 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
95/// Extract a declared version requirement from one dependency line.
96///
97/// Handles both manifest shapes:
98/// - `soroban-sdk = { version = "28.0.0", features = [...] }`
99/// - `soroban-sdk = "=27.0.6"`
100fn 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    // Plain-string requirement: `soroban-sdk = "=27.0.6"`.
113    if let Some(eq) = line.find('=') {
114        return quoted(&line[eq + 1..]);
115    }
116    None
117}
118
119/// True when the manifest's *own* dependency tables declare `soroban-sdk >= 28`.
120///
121/// Only `[dependencies]` / `[dependencies.<name>]` sections are scanned: dev,
122/// build, `[patch]` and `[replace]` sections, doc comments, and workspace-
123/// inherited requirements must not silently flip the build path. The table
124/// form carries the package name in the section header, so it is tracked
125/// separately from inline `soroban-sdk = { ... }` lines.
126fn requires_sdk_28(manifest: &str) -> bool {
127    // Package named by the current `[dependencies.<name>]` header, if any.
128    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
154/// Does this contract need the canonical Protocol 28 build system
155/// (`stellar contract build`), i.e. does it declare soroban-sdk >= 28?
156///
157/// `pub(crate)` so the scaffold tests can prove a generated Protocol 28
158/// project routes through the canonical build path.
159pub(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
166/// Installed `stellar-cli` version as `(major, minor)`, read from
167/// `stellar --version` (first line, e.g. `stellar 28.1.0 (c0f4d0da...)`).
168fn 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
177/// Builds all contracts defined in the DevKitConfig.
178///
179/// For each contract, it navigates to the configured `path` and runs:
180/// `cargo build --target wasm32v1-none --release`
181///
182/// The build proceeds in the dependency-resolved deploy order produced by
183/// [`crate::project::resolve_deploy_order`], so a malformed graph
184/// (unknown/self/duplicate dependency or a cycle) is rejected up front with a
185/// clear error before any `cargo` invocation.
186pub fn build_workspace(config: &DevKitConfig) -> Result<Vec<BuildResult>, BuildError> {
187    if config.contracts.is_empty() {
188        return Err(BuildError::MissingConfig);
189    }
190
191    // 4.2 — validate + order via the single shared resolver. This ensures
192    // build, deploy, and lock generation all use the same resolved graph.
193    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        // Protocol 28 (soroban-sdk >= 28): spec shaking v2 is unconditional, so
210        // only `stellar contract build` (stellar-cli >= 25.2.0) can produce the
211        // final stripped spec. Detect the requirement from the manifest, then
212        // either build canonically or fail with an actionable error — plain
213        // `cargo build` would exit 101, and forcing the guard env var would ship
214        // an unshaken spec (measured: 11 spec entries vs 1 on a boundary-only
215        // type), which is an artifact v28 does not define.
216        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            // Execute cargo build (unchanged path for pre-v28 projects)
247            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        // We assume a standard Soroban project structure where Cargo.toml has a package name.
264        // For sdkt build, we will attempt to extract the expected artifact name from Cargo.toml,
265        // or just glob the target/<target>/release/*.wasm dir.
266        // For stability without adding `cargo-metadata` dependency, we will look for any .wasm file
267        // generated in the release directory.
268        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    // 4.1 — generate `sdkt.lock` next to `.sdkt.toml` recording every built
294    // artifact's SHA-256 and the deterministic deploy order. Advisory only:
295    // if lock generation fails (e.g. an artifact vanished between build and
296    // hashing), surface a warning but do not fail the build.
297    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                    // Advisory lock report goes to stderr, not stdout, so
302                    // `sdkt build --format json` emits the JSON document alone
303                    // and stays machine-parseable. The information is
304                    // unchanged for a human running the pretty form.
305                    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    // ---- Protocol 28 build-system detection ----
351
352    #[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        // v28-only project (the soroban-examples shape)
365        assert!(requires_sdk_28(
366            "[dependencies]\nsoroban-sdk = { version = \"28.0.0\" }\n"
367        ));
368        // pre-28 projects keep the legacy cargo path
369        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        // table form
373        assert!(requires_sdk_28(
374            "[dependencies.soroban-sdk]\nversion = \"28.1.0\"\n"
375        ));
376        // unreadable requirement -> conservative legacy path
377        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}