1use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct TemplateFile {
26 pub path: String,
29 pub body: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct ProjectTemplate {
38 pub name: String,
41 pub files: Vec<TemplateFile>,
43}
44
45impl ProjectTemplate {
46 #[must_use]
49 pub fn render(&self, project_name: &str) -> Vec<(String, String)> {
50 let snake = snake_case(project_name);
51 let subst = |s: &str| {
52 s.replace("{{name_snake}}", &snake)
53 .replace("{{name}}", project_name)
54 };
55 self.files
56 .iter()
57 .map(|f| (subst(&f.path), subst(&f.body)))
58 .collect()
59 }
60}
61
62#[must_use]
67pub fn snake_case(name: &str) -> String {
68 let mut out = String::with_capacity(name.len());
69 let mut last_us = false;
70 for ch in name.chars() {
71 if ch.is_ascii_alphanumeric() {
72 out.extend(ch.to_lowercase());
73 last_us = false;
74 } else if !last_us {
75 out.push('_');
76 last_us = true;
77 }
78 }
79 let trimmed = out.trim_matches('_');
80 if trimmed.is_empty() {
81 return "project".to_string();
82 }
83 if trimmed.starts_with(|c: char| c.is_ascii_digit()) {
84 format!("_{trimmed}")
85 } else {
86 trimmed.to_string()
87 }
88}
89
90#[must_use]
95pub fn builtin_project_templates() -> Vec<ProjectTemplate> {
96 vec![pyo3_template(), python_template(), rust_template()]
97}
98
99fn file(path: &str, body: &str) -> TemplateFile {
100 TemplateFile {
101 path: path.to_string(),
102 body: body.to_string(),
103 }
104}
105
106fn pyo3_template() -> ProjectTemplate {
109 ProjectTemplate {
110 name: "pyo3".into(),
111 files: vec![
112 file(
113 "Cargo.toml",
114 "[package]\n\
115 name = \"{{name}}\"\n\
116 version = \"0.1.0\"\n\
117 edition = \"2021\"\n\
118 \n\
119 [lib]\n\
120 name = \"{{name_snake}}\"\n\
121 crate-type = [\"cdylib\"]\n\
122 \n\
123 [dependencies]\n\
124 pyo3 = { version = \"0.28\", features = [\"extension-module\"] }\n",
125 ),
126 file(
127 "pyproject.toml",
128 "[build-system]\n\
129 requires = [\"maturin>=1.5,<2\"]\n\
130 build-backend = \"maturin\"\n\
131 \n\
132 [project]\n\
133 name = \"{{name}}\"\n\
134 version = \"0.1.0\"\n\
135 requires-python = \">=3.9\"\n\
136 \n\
137 [tool.maturin]\n\
138 features = [\"pyo3/extension-module\"]\n",
139 ),
140 file(
141 "src/lib.rs",
142 "use pyo3::prelude::*;\n\
143 \n\
144 /// Add two integers (example).\n\
145 #[pyfunction]\n\
146 fn add(a: i64, b: i64) -> i64 {\n \
147 a + b\n\
148 }\n\
149 \n\
150 /// The `{{name_snake}}` extension module.\n\
151 #[pymodule]\n\
152 fn {{name_snake}}(m: &Bound<'_, PyModule>) -> PyResult<()> {\n \
153 m.add_function(wrap_pyfunction!(add, m)?)?;\n \
154 Ok(())\n\
155 }\n",
156 ),
157 file(".gitignore", "/target\n*.so\n__pycache__/\n.scratch/\n"),
158 ],
159 }
160}
161
162fn python_template() -> ProjectTemplate {
164 ProjectTemplate {
165 name: "python".into(),
166 files: vec![
167 file(
168 "pyproject.toml",
169 "[build-system]\n\
170 requires = [\"hatchling\"]\n\
171 build-backend = \"hatchling.build\"\n\
172 \n\
173 [project]\n\
174 name = \"{{name}}\"\n\
175 version = \"0.1.0\"\n\
176 requires-python = \">=3.9\"\n",
177 ),
178 file(
179 "src/{{name_snake}}/__init__.py",
180 "\"\"\"The {{name}} package.\"\"\"\n\
181 \n\
182 \n\
183 def add(a: int, b: int) -> int:\n \
184 \"\"\"Return the sum of two integers.\"\"\"\n \
185 return a + b\n",
186 ),
187 file(
188 "tests/test_smoke.py",
189 "from {{name_snake}} import add\n\
190 \n\
191 \n\
192 def test_add() -> None:\n \
193 assert add(2, 2) == 4\n",
194 ),
195 file(
196 ".gitignore",
197 "__pycache__/\n*.egg-info/\ndist/\n.scratch/\n",
198 ),
199 ],
200 }
201}
202
203fn rust_template() -> ProjectTemplate {
205 ProjectTemplate {
206 name: "rust".into(),
207 files: vec![
208 file(
209 "Cargo.toml",
210 "[package]\n\
211 name = \"{{name}}\"\n\
212 version = \"0.1.0\"\n\
213 edition = \"2021\"\n\
214 \n\
215 [dependencies]\n",
216 ),
217 file(
218 "src/lib.rs",
219 "/// Add two integers (example).\n\
220 pub fn add(a: i64, b: i64) -> i64 {\n \
221 a + b\n\
222 }\n\
223 \n\
224 #[cfg(test)]\n\
225 mod tests {\n \
226 use super::*;\n\
227 \n \
228 #[test]\n \
229 fn add_sums() {\n \
230 assert_eq!(add(2, 2), 4);\n \
231 }\n\
232 }\n",
233 ),
234 file(".gitignore", "/target\n.scratch/\n"),
235 ],
236 }
237}
238
239#[must_use]
244pub fn load_templates_from_dir(dir: &std::path::Path) -> Vec<ProjectTemplate> {
245 let Ok(entries) = std::fs::read_dir(dir) else {
246 return Vec::new();
247 };
248 let mut out = Vec::new();
249 for entry in entries.flatten() {
250 let path = entry.path();
251 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
252 continue;
253 }
254 if let Ok(Ok(t)) =
255 std::fs::read_to_string(&path).map(|s| toml::from_str::<ProjectTemplate>(&s))
256 {
257 out.push(t);
258 }
259 }
260 out
261}
262
263#[must_use]
267pub fn merge_project_templates(layers: Vec<Vec<ProjectTemplate>>) -> Vec<ProjectTemplate> {
268 let mut order: Vec<String> = Vec::new();
269 let mut by_name: std::collections::HashMap<String, ProjectTemplate> =
270 std::collections::HashMap::new();
271 for layer in layers {
272 for t in layer {
273 if !by_name.contains_key(&t.name) {
274 order.push(t.name.clone());
275 }
276 by_name.insert(t.name.clone(), t);
277 }
278 }
279 order
280 .into_iter()
281 .filter_map(|n| by_name.remove(&n))
282 .collect()
283}
284
285#[must_use]
288pub fn resolved_project_templates() -> Vec<ProjectTemplate> {
289 let dropins = crate::config::Config::user_config_dir()
290 .map(|d| load_templates_from_dir(&d.join("templates")))
291 .unwrap_or_default();
292 merge_project_templates(vec![builtin_project_templates(), dropins])
293}
294
295#[must_use]
297pub fn resolved_project_template(name: &str) -> Option<ProjectTemplate> {
298 resolved_project_templates()
299 .into_iter()
300 .find(|t| t.name == name)
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn builtins_cover_the_favored_modes() {
309 let builtins = builtin_project_templates();
310 let names: Vec<&str> = builtins.iter().map(|t| t.name.as_str()).collect();
311 assert!(names.contains(&"pyo3"), "{names:?}");
312 assert!(names.contains(&"python"), "{names:?}");
313 assert!(names.contains(&"rust"), "{names:?}");
314 }
315
316 #[test]
317 fn snake_case_makes_a_legal_identifier() {
318 assert_eq!(snake_case("my-cool.pkg"), "my_cool_pkg");
319 assert_eq!(snake_case("My Pkg"), "my_pkg");
320 assert_eq!(snake_case("2fast"), "_2fast");
321 assert_eq!(snake_case("---"), "project");
322 assert_eq!(snake_case("already_snake"), "already_snake");
323 }
324
325 #[test]
326 fn render_substitutes_name_and_leaves_no_placeholders() {
327 let files = pyo3_template().render("mypkg");
328 for (path, body) in &files {
329 assert!(!path.contains("{{"), "unsubstituted path: {path}");
330 assert!(!body.contains("{{"), "unsubstituted body in {path}");
331 }
332 let lib = files
334 .iter()
335 .find(|(p, _)| p == "src/lib.rs")
336 .map(|(_, b)| b.as_str())
337 .unwrap();
338 assert!(lib.contains("fn mypkg(m: &Bound"), "{lib}");
339 }
340
341 #[test]
342 fn pyo3_scaffold_round_trips_with_the_pyo3_pack() {
343 let paths: Vec<String> = pyo3_template()
347 .render("demo")
348 .into_iter()
349 .map(|(p, _)| p)
350 .collect();
351 let pyo3_pack = crate::tooling::builtin_tooling_packs()
352 .into_iter()
353 .find(|p| p.name == "pyo3")
354 .unwrap();
355 for marker in &pyo3_pack.detect {
356 assert!(
357 paths.contains(marker),
358 "scaffold should include the pack marker {marker}: {paths:?}"
359 );
360 }
361 }
362
363 #[test]
364 fn a_dropin_overrides_a_builtin_and_adds_a_new_ecosystem() {
365 let dropins = vec![
366 ProjectTemplate {
367 name: "rust".into(),
368 files: vec![file("Cargo.toml", "# custom\n")],
369 },
370 ProjectTemplate {
371 name: "zig".into(),
372 files: vec![file("build.zig", "// zig\n")],
373 },
374 ];
375 let merged = merge_project_templates(vec![builtin_project_templates(), dropins]);
376 let rust = merged.iter().find(|t| t.name == "rust").unwrap();
377 assert_eq!(rust.files.len(), 1, "drop-in replaced the built-in rust");
378 assert!(
379 merged.iter().any(|t| t.name == "zig"),
380 "new ecosystem added"
381 );
382 }
383
384 #[test]
385 fn template_parses_from_toml() {
386 let t: ProjectTemplate = toml::from_str(
387 "name = \"mini\"\n\
388 [[files]]\n\
389 path = \"README.md\"\n\
390 body = \"# {{name}}\\n\"\n",
391 )
392 .unwrap();
393 assert_eq!(t.name, "mini");
394 assert_eq!(t.render("hello")[0].1, "# hello\n");
395 }
396}