Skip to main content

sdkt_core/
project.rs

1use crate::config::DevKitConfig;
2use crate::package::topo_sort;
3use std::collections::HashMap;
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, PartialEq)]
8pub enum ProjectError {
9    MissingConfig,
10    CircularDependency(String),
11    UnknownDependency(String),
12    SelfDependency(String),
13    DuplicateDependency(String),
14    MissingArtifact(String),
15    BuildRequired(String),
16}
17
18impl fmt::Display for ProjectError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            ProjectError::MissingConfig => write!(f, "No [contracts] configured in .sdkt.toml"),
22            ProjectError::CircularDependency(path) => write!(
23                f,
24                "Circular dependency detected in contract deployment graph: {}",
25                path
26            ),
27            ProjectError::UnknownDependency(dep) => write!(
28                f,
29                "Contract specifies a dependency that is not defined in [contracts]: {}",
30                dep
31            ),
32            ProjectError::SelfDependency(alias) => write!(
33                f,
34                "Contract '{}' lists itself as a dependency (self-dependency)",
35                alias
36            ),
37            ProjectError::DuplicateDependency(alias) => write!(
38                f,
39                "Contract '{}' declares the same dependency more than once",
40                alias
41            ),
42            ProjectError::MissingArtifact(path) => {
43                write!(f, "Expected WASM artifact not found at: {}", path)
44            }
45            ProjectError::BuildRequired(alias) => write!(
46                f,
47                "WASM artifact missing for contract '{}'. Run `sdkt build` first.",
48                alias
49            ),
50        }
51    }
52}
53
54impl std::error::Error for ProjectError {}
55
56/// A resolved contract ready for deployment.
57#[derive(Debug, PartialEq)]
58pub struct ResolvedContract {
59    pub alias: String,
60    pub path: String,
61    pub wasm_artifact: PathBuf,
62}
63
64/// Resolves the deployment order based on the merged `deploy_after` +
65/// `depends_on` dependency rules.
66///
67/// Graph validation (self-dependency, duplicate dependency, unknown
68/// dependency) runs first, then the deterministic ordering and cycle detection
69/// are delegated to the shared Kahn's-algorithm core [`topo_sort`] (also used by
70/// package manifest validation). This keeps a single topological-sort
71/// implementation across the crate.
72///
73/// Returns the ordered list of contract aliases to deploy. Every consumer
74/// (build, deploy, lock generation) calls this, so they all share the same
75/// validated, deterministic ordering.
76pub fn resolve_deploy_order(config: &DevKitConfig) -> Result<Vec<String>, ProjectError> {
77    if config.contracts.is_empty() {
78        return Err(ProjectError::MissingConfig);
79    }
80
81    // Build the dependency graph (alias -> the contracts it depends on),
82    // validating as we go. The shared topo_sort detects cycles; the other
83    // graph errors are checked here so the original error messages are kept.
84    let mut graph: HashMap<String, Vec<String>> = HashMap::new();
85    for alias in config.contracts.keys() {
86        graph.entry(alias.clone()).or_default();
87    }
88
89    for (alias, contract_cfg) in &config.contracts {
90        // Merge both dependency spellings (4.2: `depends_on` canonical,
91        // `deploy_after` legacy). De-duplicate so a contract listed twice
92        // does not inflate the in-degree.
93        let mut merged: Vec<String> = Vec::new();
94        for dep in contract_cfg
95            .deploy_after
96            .iter()
97            .chain(contract_cfg.depends_on.iter())
98        {
99            if merged.contains(dep) {
100                return Err(ProjectError::DuplicateDependency(alias.clone()));
101            }
102            // Self-dependency check.
103            if dep == alias {
104                return Err(ProjectError::SelfDependency(alias.clone()));
105            }
106            // Unknown dependency check.
107            if !config.contracts.contains_key(dep) {
108                return Err(ProjectError::UnknownDependency(dep.clone()));
109            }
110            merged.push(dep.clone());
111        }
112
113        // Record `alias` depends on each `dep`.
114        graph.get_mut(alias).unwrap().extend(merged);
115    }
116
117    // Delegate cycle detection + deterministic ordering to the shared core.
118    topo_sort(&graph).map_err(ProjectError::CircularDependency)
119}
120
121/// Validate the dependency graph without resolving a concrete deploy order.
122///
123/// Convenience wrapper that reuses [`resolve_deploy_order`] (which delegates
124/// cycle detection to the shared [`crate::package::topo_sort`]) and discards the
125/// ordering. Returns a clear error for any invalid graph (self-dependency,
126/// duplicate dependency, unknown dependency, or cycle).
127pub fn validate_project(config: &DevKitConfig) -> Result<(), ProjectError> {
128    resolve_deploy_order(config).map(|_| ())
129}
130
131/// Resolves the deployment sequence and validates that all required WASM artifacts exist.
132/// Does NOT perform actual deployment (which requires sdkt-rpc).
133pub fn resolve_project(config: &DevKitConfig) -> Result<Vec<ResolvedContract>, ProjectError> {
134    let ordered_aliases = resolve_deploy_order(config)?;
135    let mut resolved = Vec::new();
136
137    for alias in ordered_aliases {
138        let cfg = config.contracts.get(&alias).unwrap();
139        let path = Path::new(&cfg.path);
140
141        // Attempt to locate the WASM artifact in the standard target directory
142        let target_dir = path
143            .join("target")
144            .join(crate::build::WASM_BUILD_TARGET)
145            .join("release");
146
147        let mut found_wasm = None;
148        if target_dir.exists() {
149            if let Ok(entries) = std::fs::read_dir(&target_dir) {
150                for entry in entries.flatten() {
151                    let p = entry.path();
152                    if p.extension().is_some_and(|ext| ext == "wasm") {
153                        // For determinism, if multiple exist, pick the first (or ideally, we'd enforce one).
154                        found_wasm = Some(p);
155                        break;
156                    }
157                }
158            }
159        }
160
161        let wasm_artifact = found_wasm.ok_or_else(|| ProjectError::BuildRequired(alias.clone()))?;
162
163        resolved.push(ResolvedContract {
164            alias,
165            path: cfg.path.clone(),
166            wasm_artifact,
167        });
168    }
169
170    Ok(resolved)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::config::{ContractConfig, DevKitConfig};
177    use std::collections::HashMap;
178
179    fn cfg(pairs: Vec<(&str, Vec<String>, Vec<String>)>) -> DevKitConfig {
180        let mut map = HashMap::new();
181        for (alias, deploy_after, depends_on) in pairs {
182            map.insert(
183                alias.to_string(),
184                ContractConfig {
185                    path: format!("contracts/{}", alias),
186                    deploy_after,
187                    depends_on,
188                },
189            );
190        }
191        DevKitConfig {
192            contracts: map,
193            ..Default::default()
194        }
195    }
196
197    #[test]
198    fn test_missing_config() {
199        let config = DevKitConfig::default();
200        let res = resolve_deploy_order(&config);
201        assert!(matches!(res, Err(ProjectError::MissingConfig)));
202    }
203
204    #[test]
205    fn test_single_contract() {
206        let config = cfg(vec![("token", vec![], vec![])]);
207        let res = resolve_deploy_order(&config).unwrap();
208        assert_eq!(res, vec!["token".to_string()]);
209    }
210
211    #[test]
212    fn test_multiple_with_dependencies() {
213        let config = cfg(vec![
214            (
215                "router",
216                vec!["token".to_string(), "amm".to_string()],
217                vec![],
218            ),
219            ("token", vec![], vec![]),
220            ("amm", vec!["token".to_string()], vec![]),
221        ]);
222        let res = resolve_deploy_order(&config).unwrap();
223        assert_eq!(
224            res,
225            vec!["token".to_string(), "amm".to_string(), "router".to_string()]
226        );
227    }
228
229    #[test]
230    fn test_depends_on_field() {
231        // 4.2 canonical field used instead of deploy_after.
232        let config = cfg(vec![
233            ("router", vec![], vec!["token".to_string()]),
234            ("token", vec![], vec![]),
235        ]);
236        let res = resolve_deploy_order(&config).unwrap();
237        assert_eq!(res, vec!["token".to_string(), "router".to_string()]);
238    }
239
240    #[test]
241    fn test_merged_deploy_after_and_depends_on() {
242        // A contract may split its dependencies across both spellings.
243        let config = cfg(vec![
244            ("router", vec!["token".to_string()], vec!["amm".to_string()]),
245            ("token", vec![], vec![]),
246            ("amm", vec![], vec!["token".to_string()]),
247        ]);
248        let res = resolve_deploy_order(&config).unwrap();
249        assert_eq!(
250            res,
251            vec!["token".to_string(), "amm".to_string(), "router".to_string()]
252        );
253    }
254
255    #[test]
256    fn test_unknown_dependency() {
257        let config = cfg(vec![("router", vec!["does_not_exist".to_string()], vec![])]);
258        match resolve_deploy_order(&config) {
259            Err(ProjectError::UnknownDependency(d)) => assert_eq!(d, "does_not_exist"),
260            _ => panic!("Expected UnknownDependency"),
261        }
262    }
263
264    #[test]
265    fn test_self_dependency() {
266        let config = cfg(vec![("token", vec!["token".to_string()], vec![])]);
267        match resolve_deploy_order(&config) {
268            Err(ProjectError::SelfDependency(a)) => assert_eq!(a, "token"),
269            _ => panic!("Expected SelfDependency"),
270        }
271    }
272
273    #[test]
274    fn test_duplicate_dependency() {
275        // Same dependency declared twice (once per spelling) -> duplicate.
276        let config = cfg(vec![
277            (
278                "router",
279                vec!["token".to_string()],
280                vec!["token".to_string()],
281            ),
282            ("token", vec![], vec![]),
283        ]);
284        match resolve_deploy_order(&config) {
285            Err(ProjectError::DuplicateDependency(a)) => assert_eq!(a, "router"),
286            other => panic!("Expected DuplicateDependency, got {:?}", other),
287        }
288    }
289
290    #[test]
291    fn test_circular_dependency() {
292        let config = cfg(vec![
293            ("a", vec!["b".to_string()], vec![]),
294            ("b", vec!["a".to_string()], vec![]),
295        ]);
296        match resolve_deploy_order(&config) {
297            Err(ProjectError::CircularDependency(d)) => assert!(d == "a" || d == "b"),
298            _ => panic!("Expected CircularDependency"),
299        }
300    }
301
302    #[test]
303    fn test_deterministic_ordering() {
304        // Diamond: d -> {b, c}, b -> a, c -> a. Order must be stable.
305        let config = cfg(vec![
306            ("d", vec!["b".to_string(), "c".to_string()], vec![]),
307            ("b", vec!["a".to_string()], vec![]),
308            ("c", vec!["a".to_string()], vec![]),
309            ("a", vec![], vec![]),
310        ]);
311        let first = resolve_deploy_order(&config).unwrap();
312        for _ in 0..20 {
313            assert_eq!(resolve_deploy_order(&config).unwrap(), first);
314        }
315        // a before b and c; b and c before d.
316        let pos = |s: &str| first.iter().position(|x| x == s).unwrap();
317        assert!(pos("a") < pos("b"));
318        assert!(pos("a") < pos("c"));
319        assert!(pos("b") < pos("d"));
320        assert!(pos("c") < pos("d"));
321    }
322
323    #[test]
324    fn test_validate_project_ok_and_err() {
325        let ok = cfg(vec![
326            ("router", vec![], vec!["token".to_string()]),
327            ("token", vec![], vec![]),
328        ]);
329        assert!(validate_project(&ok).is_ok());
330
331        let bad = cfg(vec![("token", vec!["token".to_string()], vec![])]);
332        assert!(matches!(
333            validate_project(&bad),
334            Err(ProjectError::SelfDependency(_))
335        ));
336    }
337}