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#[derive(Debug)]
7pub enum BuildError {
8    MissingConfig,
9    PathNotFound(String),
10    CargoFailed { path: String, stderr: String },
11    ArtifactNotFound(String),
12    InvalidProject(String),
13}
14
15impl fmt::Display for BuildError {
16    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
17        match self {
18            BuildError::MissingConfig => write!(f, "No [contracts] configured in .sdkt.toml"),
19            BuildError::PathNotFound(path) => write!(f, "Contract path does not exist: {}", path),
20            BuildError::CargoFailed { path, stderr } => {
21                write!(f, "Cargo build failed in {}:\n{}", path, stderr)
22            }
23            BuildError::ArtifactNotFound(path) => {
24                write!(f, "Expected WASM artifact not found at: {}", path)
25            }
26            BuildError::InvalidProject(msg) => {
27                write!(f, "Invalid project dependency graph: {}", msg)
28            }
29        }
30    }
31}
32
33impl std::error::Error for BuildError {}
34
35/// Result of building a single contract.
36#[derive(Debug, PartialEq)]
37pub struct BuildResult {
38    pub alias: String,
39    pub path: String,
40    pub wasm_artifact: PathBuf,
41}
42
43/// Builds all contracts defined in the DevKitConfig.
44///
45/// For each contract, it navigates to the configured `path` and runs:
46/// `cargo build --target wasm32-unknown-unknown --release`
47///
48/// The build proceeds in the dependency-resolved deploy order produced by
49/// [`crate::project::resolve_deploy_order`], so a malformed graph
50/// (unknown/self/duplicate dependency or a cycle) is rejected up front with a
51/// clear error before any `cargo` invocation.
52pub fn build_workspace(config: &DevKitConfig) -> Result<Vec<BuildResult>, BuildError> {
53    if config.contracts.is_empty() {
54        return Err(BuildError::MissingConfig);
55    }
56
57    // M34.2 — validate + order via the single shared resolver. This ensures
58    // build, deploy, and lock generation all use the same resolved graph.
59    let ordered = crate::project::resolve_deploy_order(config)
60        .map_err(|e| BuildError::InvalidProject(e.to_string()))?;
61
62    let mut results = Vec::new();
63
64    for alias in &ordered {
65        let contract_cfg = config
66            .contracts
67            .get(alias)
68            .expect("alias from resolved order");
69        let path = Path::new(&contract_cfg.path);
70
71        if !path.exists() || !path.is_dir() {
72            return Err(BuildError::PathNotFound(contract_cfg.path.clone()));
73        }
74
75        // Execute cargo build
76        let output = Command::new("cargo")
77            .arg("build")
78            .arg("--target")
79            .arg("wasm32-unknown-unknown")
80            .arg("--release")
81            .current_dir(path)
82            .output()
83            .map_err(|e| BuildError::CargoFailed {
84                path: contract_cfg.path.clone(),
85                stderr: e.to_string(),
86            })?;
87
88        if !output.status.success() {
89            return Err(BuildError::CargoFailed {
90                path: contract_cfg.path.clone(),
91                stderr: String::from_utf8_lossy(&output.stderr).to_string(),
92            });
93        }
94
95        // We assume a standard Soroban project structure where Cargo.toml has a package name.
96        // For sdkt build, we will attempt to extract the expected artifact name from Cargo.toml,
97        // or just glob the target/wasm32-unknown-unknown/release/*.wasm dir.
98        // For stability without adding `cargo-metadata` dependency, we will look for any .wasm file
99        // generated in the release directory.
100        let target_dir = path
101            .join("target")
102            .join("wasm32-unknown-unknown")
103            .join("release");
104
105        let mut found_wasm = None;
106        if target_dir.exists() {
107            if let Ok(entries) = std::fs::read_dir(&target_dir) {
108                for entry in entries.flatten() {
109                    let p = entry.path();
110                    if p.extension().is_some_and(|ext| ext == "wasm") {
111                        found_wasm = Some(p);
112                        break;
113                    }
114                }
115            }
116        }
117
118        let wasm_artifact = found_wasm
119            .ok_or_else(|| BuildError::ArtifactNotFound(target_dir.display().to_string()))?;
120
121        results.push(BuildResult {
122            alias: alias.clone(),
123            path: contract_cfg.path.clone(),
124            wasm_artifact,
125        });
126    }
127
128    // M34.1 — generate `sdkt.lock` next to `.sdkt.toml` recording every built
129    // artifact's SHA-256 and the deterministic deploy order. Advisory only:
130    // if lock generation fails (e.g. an artifact vanished between build and
131    // hashing), surface a warning but do not fail the build.
132    if let Ok(lock) = crate::lock::generate_lock(Path::new("."), config) {
133        match crate::lock::write_lock(Path::new("."), &lock) {
134            Ok(path) => {
135                if let Ok(toml) = crate::lock::lock_to_toml(&lock) {
136                    println!("✓ Wrote {}", path.display());
137                    println!("{}", toml);
138                }
139            }
140            Err(e) => eprintln!("Warning: could not write sdkt.lock: {}", e),
141        }
142    }
143
144    Ok(results)
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::config::ContractConfig;
151    use std::collections::HashMap;
152
153    #[test]
154    fn test_build_empty_config() {
155        let config = DevKitConfig::default();
156        let res = build_workspace(&config);
157        assert!(matches!(res, Err(BuildError::MissingConfig)));
158    }
159
160    #[test]
161    fn test_build_missing_path() {
162        let mut config = DevKitConfig::default();
163        let mut contracts = HashMap::new();
164        contracts.insert(
165            "token".to_string(),
166            ContractConfig {
167                path: "does_not_exist_xyz".to_string(),
168                deploy_after: vec![],
169                depends_on: vec![],
170            },
171        );
172        config.contracts = contracts;
173
174        let res = build_workspace(&config);
175        match res {
176            Err(BuildError::PathNotFound(p)) => assert_eq!(p, "does_not_exist_xyz"),
177            _ => panic!("Expected PathNotFound"),
178        }
179    }
180}