Skip to main content

doido_generators/generators/
new.rs

1//! New application skeleton rendered from embedded files under `templates/new/`.
2//! Placeholders: `{doido_name}`, `{doido_db_url}`, `{doido_sqlx_feature}`,
3//! `{doido_path}` (absolute workspace root captured at compile time, used for
4//! local `doido-*` path dependencies), and the per-crate dependency specs
5//! `{doido_dep}` / `{doido_controller_dep}` / `{doido_model_dep}` which render as
6//! a local `path` dep in a workspace build or a crates.io `version` dep once
7//! `doido-generators` is published (see [`doido_dependency`]).
8//!
9//! Template files carrying a trailing `.template` suffix (e.g. `Cargo.toml.template`)
10//! have the suffix stripped on output; the suffix keeps `cargo package` from treating
11//! `templates/new/` as a nested crate and excluding it from the published tarball.
12
13use crate::generator::{GeneratedFile, Generator};
14use doido_core::{anyhow, Result};
15use include_dir::{include_dir, Dir, DirEntry};
16
17/// Embedded filesystem tree merged at compile time from `templates/new`.
18static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
19
20struct TemplateContext<'a> {
21    name: &'a str,
22    db_url: String,
23    db_url_test: String,
24    db_url_production: String,
25    sqlx_feature: &'a str,
26}
27
28/// Renders the Cargo dependency source for a first-party `doido-*` crate.
29///
30/// `subdir` is the crate's directory under the workspace root (e.g. `doido`,
31/// `doido-controller`). In a local workspace build this returns a `path`
32/// dependency so a freshly generated app compiles against the in-tree framework;
33/// in a published build (siblings absent) it returns a `version` dependency that
34/// resolves the matching release from crates.io.
35fn doido_dependency(subdir: &str) -> String {
36    dependency_spec(
37        crate::TEMPLATE_USE_PATH_DEPS,
38        crate::TEMPLATE_WORKSPACE_PATH,
39        crate::DOIDO_VERSION,
40        subdir,
41    )
42}
43
44/// Pure core of [`doido_dependency`]: builds a `path` or `version` dependency
45/// source string from the given inputs. Split out so both branches are unit
46/// testable without depending on how this crate was built.
47fn dependency_spec(use_path: bool, workspace_path: &str, version: &str, subdir: &str) -> String {
48    if use_path {
49        format!("{{ path = \"{workspace_path}/{subdir}\" }}")
50    } else {
51        format!("\"{version}\"")
52    }
53}
54
55fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
56    template
57        .replace("{doido_name}", ctx.name)
58        .replace("{doido_db_url_test}", &ctx.db_url_test)
59        .replace("{doido_db_url_production}", &ctx.db_url_production)
60        .replace("{doido_db_url}", &ctx.db_url)
61        .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
62        .replace("{doido_dep}", &doido_dependency("doido"))
63        .replace(
64            "{doido_controller_dep}",
65            &doido_dependency("doido-controller"),
66        )
67        .replace("{doido_model_dep}", &doido_dependency("doido-model"))
68        .replace("{doido_path}", crate::TEMPLATE_WORKSPACE_PATH)
69}
70
71fn collect_from_dir(
72    dir: &Dir<'_>,
73    ctx: &TemplateContext<'_>,
74    app_name: &str,
75    out: &mut Vec<GeneratedFile>,
76) -> Result<()> {
77    for entry in dir.entries() {
78        match entry {
79            DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
80            DirEntry::File(f) => {
81                // `include_dir` stores paths relative to the embedded root (`templates/new/`)
82                // for every file, including nested paths like `src/main.rs`.
83                let relative = f.path();
84                let raw = f.contents_utf8().ok_or_else(|| {
85                    anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
86                })?;
87                let rendered = substitute_template(raw, ctx);
88                // Template manifests are stored with a trailing `.template` suffix
89                // (e.g. `Cargo.toml.template`) so `cargo package` doesn't mistake
90                // `templates/app/` for a nested crate and drop it from the tarball.
91                // Strip the suffix when writing the generated app to disk.
92                let relative = relative.to_string_lossy().replace('\\', "/");
93                let relative = relative.strip_suffix(".template").unwrap_or(&relative);
94                let disk_path = format!("{app_name}/{relative}");
95                out.push(GeneratedFile {
96                    path: disk_path,
97                    content: rendered,
98                });
99            }
100        }
101    }
102    Ok(())
103}
104
105/// Default local connection parameters for a server backend.
106struct DbDefaults {
107    /// URL scheme (`postgres` / `mysql`).
108    scheme: &'static str,
109    /// Default superuser for a local install.
110    user: &'static str,
111    /// Default development/test password (placeholder in production).
112    password: &'static str,
113    /// Default listening port.
114    port: u16,
115}
116
117/// Returns the default connection parameters for `postgres`/`mysql`, or `None`
118/// for file-based backends (sqlite) that carry no user/host/port.
119fn db_defaults(backend: &str) -> Option<DbDefaults> {
120    match backend {
121        "postgres" => Some(DbDefaults {
122            scheme: "postgres",
123            user: "postgres",
124            password: "postgres",
125            port: 5432,
126        }),
127        "mysql" => Some(DbDefaults {
128            scheme: "mysql",
129            user: "root",
130            password: "password",
131            port: 3306,
132        }),
133        _ => None,
134    }
135}
136
137/// Builds the `database.url` for one environment of a generated app.
138///
139/// Server backends (postgres/mysql) include the default user, password, host
140/// and port so the generated config is close to a working local setup, e.g.
141/// `postgres://postgres:postgres@localhost:5432/blog_development`. sqlite uses a
142/// bare file path. In **production** the password is a `CHANGE_ME` placeholder
143/// that must be overridden (e.g. via the `DATABASE_URL` env var) — real
144/// credentials are never baked into the generated repo.
145///
146/// Note: the default credentials contain no URL-reserved characters, so no
147/// percent-encoding is needed. That would change if custom passwords were ever
148/// accepted here.
149fn default_database_url(backend: &str, name: &str, env: &str) -> String {
150    match db_defaults(backend) {
151        Some(d) => {
152            let password = if env == "production" {
153                "CHANGE_ME"
154            } else {
155                d.password
156            };
157            format!(
158                "{}://{}:{}@localhost:{}/{}_{}",
159                d.scheme, d.user, password, d.port, name, env
160            )
161        }
162        None => format!("sqlite://db/{env}.db"),
163    }
164}
165
166pub struct ProjectGenerator;
167
168impl Generator for ProjectGenerator {
169    fn name(&self) -> &str {
170        "new"
171    }
172
173    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
174        let name = args
175            .first()
176            .copied()
177            .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
178
179        let database = args
180            .iter()
181            .find(|a| a.starts_with("--database="))
182            .and_then(|a| a.split_once('=').map(|(_, v)| v))
183            .unwrap_or("sqlite");
184
185        match database {
186            "sqlite" | "postgres" | "mysql" => {}
187            other => {
188                return Err(anyhow::anyhow!(
189                    "Unknown database: {}. Use sqlite, postgres, or mysql.",
190                    other
191                ));
192            }
193        }
194
195        let db_url = default_database_url(database, name, "development");
196        let db_url_test = default_database_url(database, name, "test");
197        let db_url_production = default_database_url(database, name, "production");
198
199        let sqlx_feature = match database {
200            "postgres" => "postgres",
201            "mysql" => "mysql",
202            _ => "sqlite",
203        };
204
205        let ctx = TemplateContext {
206            name,
207            db_url,
208            db_url_test,
209            db_url_production,
210            sqlx_feature,
211        };
212
213        let mut files = Vec::new();
214        collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
215        files.sort_by(|a, b| a.path.cmp(&b.path));
216        Ok(files)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::{default_database_url, dependency_spec};
223
224    #[test]
225    fn local_builds_emit_path_dependencies() {
226        assert_eq!(
227            dependency_spec(true, "/home/dev/doido", "0.0.6", "doido"),
228            "{ path = \"/home/dev/doido/doido\" }"
229        );
230        assert_eq!(
231            dependency_spec(true, "/home/dev/doido", "0.0.6", "doido-controller"),
232            "{ path = \"/home/dev/doido/doido-controller\" }"
233        );
234    }
235
236    #[test]
237    fn published_builds_emit_version_dependencies() {
238        // Once published, the sibling crates are gone, so apps resolve the
239        // matching release from crates.io — the workspace path is irrelevant.
240        assert_eq!(
241            dependency_spec(false, "/irrelevant", "0.0.6", "doido"),
242            "\"0.0.6\""
243        );
244        assert_eq!(
245            dependency_spec(false, "/irrelevant", "1.2.3", "doido-model"),
246            "\"1.2.3\""
247        );
248    }
249
250    #[test]
251    fn postgres_url_has_default_user_password_and_port() {
252        assert_eq!(
253            default_database_url("postgres", "blog", "development"),
254            "postgres://postgres:postgres@localhost:5432/blog_development"
255        );
256    }
257
258    #[test]
259    fn mysql_url_has_default_user_password_and_port() {
260        assert_eq!(
261            default_database_url("mysql", "store", "test"),
262            "mysql://root:password@localhost:3306/store_test"
263        );
264    }
265
266    #[test]
267    fn production_password_is_a_placeholder() {
268        assert_eq!(
269            default_database_url("postgres", "blog", "production"),
270            "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
271        );
272        assert_eq!(
273            default_database_url("mysql", "store", "production"),
274            "mysql://root:CHANGE_ME@localhost:3306/store_production"
275        );
276    }
277
278    #[test]
279    fn sqlite_stays_a_bare_file_path() {
280        assert_eq!(
281            default_database_url("sqlite", "blog", "development"),
282            "sqlite://db/development.db"
283        );
284    }
285}