1use std::fs;
2use std::io;
3use std::path::Path;
4
5#[derive(Debug, Clone)]
7pub struct ScaffoldConfig {
8 pub name: String,
10 pub minimal: bool,
12 pub force: bool,
14}
15
16#[derive(Debug)]
18pub struct ScaffoldResult {
19 pub files_created: Vec<String>,
21}
22
23pub fn generate_project(config: &ScaffoldConfig) -> io::Result<ScaffoldResult> {
28 let root = Path::new(&config.name);
29
30 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 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 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 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 assert!(test_content.contains("sdkt_test_hyphen_test"));
252 let _ = fs::remove_dir_all(&p);
253 }
254}