Skip to main content

sdkt_core/
scaffold.rs

1use std::fs;
2use std::io;
3use std::path::Path;
4
5/// Configuration for project scaffolding.
6#[derive(Debug, Clone)]
7pub struct ScaffoldConfig {
8    /// Project name (also used as directory name).
9    pub name: String,
10    /// If true, generate only Cargo.toml, src/lib.rs, .sdkt.toml.
11    pub minimal: bool,
12    /// If true, overwrite existing directory.
13    pub force: bool,
14}
15
16/// Result of a successful scaffold operation.
17#[derive(Debug)]
18pub struct ScaffoldResult {
19    /// Files that were created.
20    pub files_created: Vec<String>,
21}
22
23/// Generate a new Soroban contract project.
24///
25/// Creates the directory structure and all template files.
26/// Returns an error if the directory exists and `force` is false.
27pub fn generate_project(config: &ScaffoldConfig) -> io::Result<ScaffoldResult> {
28    let root = Path::new(&config.name);
29
30    // Extract just the directory name for the Rust package name
31    let package_name = root
32        .file_name()
33        .and_then(|n| n.to_str())
34        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "invalid project name"))?
35        .to_string();
36
37    if root.exists() && !config.force {
38        return Err(io::Error::new(
39            io::ErrorKind::AlreadyExists,
40            format!(
41                "directory '{}' already exists (use --force to overwrite)",
42                config.name
43            ),
44        ));
45    }
46
47    fs::create_dir_all(root.join("src"))?;
48
49    let mut created = Vec::new();
50
51    // --- Always generated ---
52
53    let crate_name = package_name.replace('-', "_");
54
55    let cargo_toml = format!(
56        r#"[package]
57name = "{name}"
58version = "0.1.0"
59edition = "2021"
60
61[dependencies]
62soroban-sdk = "21.0.0"
63
64[dev-dependencies]
65soroban-sdk = {{ version = "21.0.0", features = ["testutils"] }}
66
67[lib]
68crate-type = ["cdylib"]
69
70[profile.release]
71opt-level = "z"
72overflow-checks = true
73debug = 0
74strip = "symbols"
75debug-assertions = false
76panic = "abort"
77codegen-units = 1
78lto = true
79"#,
80        name = package_name,
81    );
82    write_template(root, "Cargo.toml", &cargo_toml, &mut created)?;
83
84    let lib_rs = r#"#![no_std]
85use soroban_sdk::{contract, contractimpl, Env};
86
87#[contract]
88pub struct Contract;
89
90#[contractimpl]
91impl Contract {
92    /// Returns a greeting number.
93    pub fn hello(_env: Env) -> u32 {
94        42
95    }
96}
97"#;
98    write_template(root, "src/lib.rs", lib_rs, &mut created)?;
99
100    let sdkt_toml = r#"[network]
101default = "testnet"
102rpc_url = "https://soroban-testnet.stellar.org"
103
104[build]
105target = "wasm32-unknown-unknown"
106"#;
107    write_template(root, ".sdkt.toml", sdkt_toml, &mut created)?;
108
109    // --- Full mode only ---
110
111    if !config.minimal {
112        let readme = format!(
113            "# {name}\n\nA Soroban smart contract project.\n\n## Build\n\n```\ncargo build --target wasm32-unknown-unknown --release\n```\n\n## Test\n\n```\ncargo test\n```\n",
114            name = package_name,
115        );
116        write_template(root, "README.md", &readme, &mut created)?;
117
118        write_template(root, ".gitignore", "/target\n", &mut created)?;
119
120        fs::create_dir_all(root.join("tests"))?;
121
122        let basic_test = format!(
123            r#"#![cfg(test)]
124
125use {crate_name}::{{Contract, ContractClient}};
126use soroban_sdk::Env;
127
128#[test]
129fn test_hello() {{
130    let env = Env::default();
131    let contract_id = env.register_contract(None, Contract);
132    let client = ContractClient::new(&env, &contract_id);
133    assert_eq!(client.hello(), 42);
134}}
135"#,
136            crate_name = crate_name,
137        );
138        write_template(root, "tests/basic.rs", &basic_test, &mut created)?;
139    }
140
141    Ok(ScaffoldResult {
142        files_created: created,
143    })
144}
145
146fn write_template(
147    root: &Path,
148    rel_path: &str,
149    content: &str,
150    created: &mut Vec<String>,
151) -> io::Result<()> {
152    fs::write(root.join(rel_path), content)?;
153    created.push(rel_path.to_string());
154    Ok(())
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::path::PathBuf;
161
162    fn tmp_dir(name: &str) -> PathBuf {
163        let p = std::env::temp_dir().join(format!("sdkt_test_{}", name));
164        let _ = fs::remove_dir_all(&p);
165        p
166    }
167
168    fn cfg(path: &Path, minimal: bool, force: bool) -> ScaffoldConfig {
169        ScaffoldConfig {
170            name: path.to_string_lossy().to_string(),
171            minimal,
172            force,
173        }
174    }
175
176    #[test]
177    fn full_scaffold_creates_all_files() {
178        let p = tmp_dir("full");
179        let res = generate_project(&cfg(&p, false, false)).unwrap();
180        assert!(p.join("Cargo.toml").exists());
181        assert!(p.join("src/lib.rs").exists());
182        assert!(p.join(".sdkt.toml").exists());
183        assert!(p.join("README.md").exists());
184        assert!(p.join(".gitignore").exists());
185        assert!(p.join("tests/basic.rs").exists());
186        assert_eq!(res.files_created.len(), 6);
187        let _ = fs::remove_dir_all(&p);
188    }
189
190    #[test]
191    fn minimal_scaffold_omits_extras() {
192        let p = tmp_dir("minimal");
193        let res = generate_project(&cfg(&p, true, false)).unwrap();
194        assert!(p.join("Cargo.toml").exists());
195        assert!(p.join("src/lib.rs").exists());
196        assert!(p.join(".sdkt.toml").exists());
197        assert!(!p.join("README.md").exists());
198        assert!(!p.join(".gitignore").exists());
199        assert!(!p.join("tests").exists());
200        assert_eq!(res.files_created.len(), 3);
201        let _ = fs::remove_dir_all(&p);
202    }
203
204    #[test]
205    fn rejects_existing_dir() {
206        let p = tmp_dir("exists");
207        fs::create_dir_all(&p).unwrap();
208        let err = generate_project(&cfg(&p, false, false)).unwrap_err();
209        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
210        let _ = fs::remove_dir_all(&p);
211    }
212
213    #[test]
214    fn force_overwrites_existing() {
215        let p = tmp_dir("force");
216        fs::create_dir_all(&p).unwrap();
217        fs::write(p.join("user_file.txt"), "keep me").unwrap();
218        let res = generate_project(&cfg(&p, false, true)).unwrap();
219        assert!(res.files_created.len() >= 6);
220        // User file not deleted
221        assert!(p.join("user_file.txt").exists());
222        let _ = fs::remove_dir_all(&p);
223    }
224
225    #[test]
226    fn cargo_toml_has_no_std_profile() {
227        let p = tmp_dir("profile");
228        generate_project(&cfg(&p, true, false)).unwrap();
229        let content = fs::read_to_string(p.join("Cargo.toml")).unwrap();
230        assert!(content.contains("[profile.release]"));
231        assert!(content.contains("panic = \"abort\""));
232        let _ = fs::remove_dir_all(&p);
233    }
234
235    #[test]
236    fn lib_rs_is_no_std() {
237        let p = tmp_dir("nostd");
238        generate_project(&cfg(&p, true, false)).unwrap();
239        let content = fs::read_to_string(p.join("src/lib.rs")).unwrap();
240        assert!(content.contains("#![no_std]"));
241        assert!(content.contains("soroban_sdk"));
242        let _ = fs::remove_dir_all(&p);
243    }
244
245    #[test]
246    fn hyphenated_name_produces_valid_crate() {
247        let p = tmp_dir("hyphen-test");
248        generate_project(&cfg(&p, false, false)).unwrap();
249        let test_content = fs::read_to_string(p.join("tests/basic.rs")).unwrap();
250        // Rust crate names use underscores
251        assert!(test_content.contains("sdkt_test_hyphen_test"));
252        let _ = fs::remove_dir_all(&p);
253    }
254}