Skip to main content

newt_core/
templates.rs

1//! **Project templates** (#892) — the DATA sibling of tooling packs (#880).
2//!
3//! A [`ProjectTemplate`] says how to bring a repo *into existence* already wired
4//! for its lifecycle; a [`ToolingPack`](crate::tooling::ToolingPack) says how to
5//! *run* the lifecycle of one that already exists. They round-trip: a project
6//! scaffolded from the `pyo3` template lays down the exact marker files
7//! (`Cargo.toml` + `pyproject.toml`) the `pyo3` pack detects, so its `check` /
8//! `test` / `format` phases resolve out of the box.
9//!
10//! Built-ins cover the operator's favored modes — **Rust + PyO3 (maturin)**,
11//! **Python**, **Rust** — kept deliberately minimal-but-buildable. Drop-in
12//! `~/.newt/templates/<name>.toml` (a serialized `ProjectTemplate`) overrides a
13//! built-in by name or adds a new ecosystem, the same merge model as packs. Pure
14//! data + pure render — no filesystem here; the CLI (`newt new`) materializes the
15//! rendered `(path, body)` pairs.
16
17use serde::{Deserialize, Serialize};
18
19/// One file a template lays down. `path` is repo-relative (forward slashes).
20/// Both `path` and `body` may use the placeholders `{{name}}` (the project name
21/// verbatim) and `{{name_snake}}` ([`snake_case`], a legal Rust/Python module
22/// identifier) — substituted at [`render`](ProjectTemplate::render) time.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25pub struct TemplateFile {
26    /// Repo-relative destination path (forward slashes; may contain `{{name}}`
27    /// / `{{name_snake}}`).
28    pub path: String,
29    /// File contents (may contain `{{name}}` / `{{name_snake}}`).
30    pub body: String,
31}
32
33/// A project scaffold, as data. `name` is the ecosystem key (`pyo3` / `python` /
34/// `rust`) and the drop-in merge key.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct ProjectTemplate {
38    /// Ecosystem key + drop-in merge key. A drop-in with an existing name
39    /// overrides the built-in; a new name adds an ecosystem.
40    pub name: String,
41    /// The files to lay down, in order.
42    pub files: Vec<TemplateFile>,
43}
44
45impl ProjectTemplate {
46    /// Render the template for `project_name`: substitute `{{name}}` and
47    /// `{{name_snake}}` in every path and body, returning `(path, body)` pairs.
48    #[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/// Convert a project name into a legal snake_case module identifier: lowercase,
63/// every run of non-alphanumeric characters collapsed to a single `_`, a leading
64/// digit prefixed with `_` (so it is a valid Rust/Python identifier). Empty →
65/// `project`.
66#[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/// The built-in project templates — minimal but **buildable**: a `pyo3` repo
91/// `maturin develop`s and imports; a `python` repo `pip install -e .`s and
92/// imports; a `rust` repo `cargo test`s. Each carries a `.gitignore` that ignores
93/// build artifacts and the `.scratch/` fence.
94#[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
106/// Rust + PyO3 (maturin). Matches the `pyo3` tooling pack (Cargo.toml +
107/// pyproject.toml). pyo3 version tracks the newt workspace (0.28, `Bound` API).
108fn 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
162/// Pure-Python (hatchling backend).
163fn 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
203/// Pure-Rust library crate.
204fn 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/// Load drop-in templates from `dir` (`~/.newt/templates/`): each `*.toml` is a
240/// serialized [`ProjectTemplate`]. Missing dir / unreadable / malformed files are
241/// skipped (best-effort, like the tooling-pack loader). Order is unspecified;
242/// [`merge_project_templates`] resolves collisions by name.
243#[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/// Merge template layers by `name`, later layers overriding earlier ones (so a
264/// drop-in with a built-in's name REPLACES it; a new name is ADDED). Result order
265/// is first-seen.
266#[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/// The resolved template set: built-ins merged under the `~/.newt/templates/`
286/// drop-ins. This is what `newt new` selects from.
287#[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/// The resolved template named `name`, if any.
296#[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        // The lib module + Cargo [lib] name use the snake identifier.
333        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        // A scaffolded pyo3 project lays down exactly the marker files the pyo3
344        // tooling pack detects — so its lifecycle resolves out of the box. Pure:
345        // we compare rendered paths to the pack's `detect`, no filesystem.
346        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}