Skip to main content

kick_rs_cli/
templates.rs

1//! Compile-time-embedded starter templates for `cargo kick new`.
2//!
3//! Each entry is `(relative_path, contents)`. Templates use a tiny
4//! `{{var}}` substitution scheme — see [`render`] — keyed off the
5//! variables in [`Vars`]. Anything that isn't a known variable is
6//! left untouched, so the template files are still valid Rust /
7//! TOML for editor tooling.
8
9/// Variables the templates can reference. Kept tiny on purpose — the
10/// scaffold should be readable code with one or two substitutions, not
11/// a templating-engine dialect.
12pub struct Vars<'a> {
13    /// Raw name as supplied on the CLI (kebab-case, e.g. "my-app").
14    /// Used in `Cargo.toml`'s `name`, README headings, etc.
15    pub project_name: &'a str,
16    /// Snake-cased form of [`Self::project_name`] for Rust crate
17    /// identifiers and tracing-subscriber log targets.
18    pub project_name_snake: &'a str,
19}
20
21/// Manifest of every file the `new` scaffold emits. Tuples of
22/// `(destination_relative_path, embedded_template_contents)`.
23///
24/// Paths are written as-is into the project directory. They include
25/// `Cargo.toml`, `src/...`, dotfiles like `.gitignore`. No path
26/// substitution — the only thing template-substituted is the file
27/// *contents*.
28pub const FILES: &[(&str, &str)] = &[
29    (
30        "Cargo.toml",
31        include_str!("../templates/new/Cargo.toml.tmpl"),
32    ),
33    (
34        "src/main.rs",
35        include_str!("../templates/new/src/main.rs.tmpl"),
36    ),
37    (
38        "src/modules/mod.rs",
39        include_str!("../templates/new/src/modules/mod.rs.tmpl"),
40    ),
41    (
42        "src/modules/hello/mod.rs",
43        include_str!("../templates/new/src/modules/hello/mod.rs.tmpl"),
44    ),
45    (
46        "src/modules/hello/handlers.rs",
47        include_str!("../templates/new/src/modules/hello/handlers.rs.tmpl"),
48    ),
49    (
50        ".env.example",
51        include_str!("../templates/new/.env.example.tmpl"),
52    ),
53    (
54        ".gitignore",
55        include_str!("../templates/new/.gitignore.tmpl"),
56    ),
57    ("README.md", include_str!("../templates/new/README.md.tmpl")),
58];
59
60/// Substitute `{{project_name}}` and `{{project_name_snake}}` into the
61/// given template. Unknown `{{...}}` sequences are passed through
62/// untouched.
63pub fn render(template: &str, vars: &Vars<'_>) -> String {
64    template
65        .replace("{{project_name_snake}}", vars.project_name_snake)
66        .replace("{{project_name}}", vars.project_name)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn render_substitutes_known_vars() {
75        let v = Vars {
76            project_name: "my-app",
77            project_name_snake: "my_app",
78        };
79        let s = render(
80            "name = \"{{project_name}}\"\ntarget = \"{{project_name_snake}}\"",
81            &v,
82        );
83        assert_eq!(s, "name = \"my-app\"\ntarget = \"my_app\"");
84    }
85
86    #[test]
87    fn render_leaves_unknown_vars_alone() {
88        let v = Vars {
89            project_name: "x",
90            project_name_snake: "x",
91        };
92        let s = render("{{unknown_thing}}", &v);
93        assert_eq!(s, "{{unknown_thing}}");
94    }
95
96    #[test]
97    fn file_manifest_is_non_empty_and_relative() {
98        assert!(!FILES.is_empty(), "scaffold must emit at least one file");
99        for (path, _) in FILES {
100            assert!(
101                !path.starts_with('/') && !path.contains(".."),
102                "manifest paths must be project-relative without traversal: {path}"
103            );
104        }
105    }
106}