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).
5//!
6//! Template files carrying a trailing `.template` suffix (e.g. `Cargo.toml.template`)
7//! have the suffix stripped on output; the suffix keeps `cargo package` from treating
8//! `templates/new/` as a nested crate and excluding it from the published tarball.
9
10use crate::generator::{GeneratedFile, Generator};
11use doido_core::{anyhow, Result};
12use include_dir::{include_dir, Dir, DirEntry};
13
14/// Embedded filesystem tree merged at compile time from `templates/new`.
15static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
16
17struct TemplateContext<'a> {
18    name: &'a str,
19    db_url: String,
20    db_url_test: String,
21    db_url_production: String,
22    sqlx_feature: &'a str,
23}
24
25fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
26    template
27        .replace("{doido_name}", ctx.name)
28        .replace("{doido_db_url_test}", &ctx.db_url_test)
29        .replace("{doido_db_url_production}", &ctx.db_url_production)
30        .replace("{doido_db_url}", &ctx.db_url)
31        .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
32        .replace("{doido_path}", crate::TEMPLATE_WORKSPACE_PATH)
33}
34
35fn collect_from_dir(
36    dir: &Dir<'_>,
37    ctx: &TemplateContext<'_>,
38    app_name: &str,
39    out: &mut Vec<GeneratedFile>,
40) -> Result<()> {
41    for entry in dir.entries() {
42        match entry {
43            DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
44            DirEntry::File(f) => {
45                // `include_dir` stores paths relative to the embedded root (`templates/new/`)
46                // for every file, including nested paths like `src/main.rs`.
47                let relative = f.path();
48                let raw = f.contents_utf8().ok_or_else(|| {
49                    anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
50                })?;
51                let rendered = substitute_template(raw, ctx);
52                // Template manifests are stored with a trailing `.template` suffix
53                // (e.g. `Cargo.toml.template`) so `cargo package` doesn't mistake
54                // `templates/app/` for a nested crate and drop it from the tarball.
55                // Strip the suffix when writing the generated app to disk.
56                let relative = relative.to_string_lossy().replace('\\', "/");
57                let relative = relative.strip_suffix(".template").unwrap_or(&relative);
58                let disk_path = format!("{app_name}/{relative}");
59                out.push(GeneratedFile {
60                    path: disk_path,
61                    content: rendered,
62                });
63            }
64        }
65    }
66    Ok(())
67}
68
69/// Default local connection parameters for a server backend.
70struct DbDefaults {
71    /// URL scheme (`postgres` / `mysql`).
72    scheme: &'static str,
73    /// Default superuser for a local install.
74    user: &'static str,
75    /// Default development/test password (placeholder in production).
76    password: &'static str,
77    /// Default listening port.
78    port: u16,
79}
80
81/// Returns the default connection parameters for `postgres`/`mysql`, or `None`
82/// for file-based backends (sqlite) that carry no user/host/port.
83fn db_defaults(backend: &str) -> Option<DbDefaults> {
84    match backend {
85        "postgres" => Some(DbDefaults {
86            scheme: "postgres",
87            user: "postgres",
88            password: "postgres",
89            port: 5432,
90        }),
91        "mysql" => Some(DbDefaults {
92            scheme: "mysql",
93            user: "root",
94            password: "password",
95            port: 3306,
96        }),
97        _ => None,
98    }
99}
100
101/// Builds the `database.url` for one environment of a generated app.
102///
103/// Server backends (postgres/mysql) include the default user, password, host
104/// and port so the generated config is close to a working local setup, e.g.
105/// `postgres://postgres:postgres@localhost:5432/blog_development`. sqlite uses a
106/// bare file path. In **production** the password is a `CHANGE_ME` placeholder
107/// that must be overridden (e.g. via the `DATABASE_URL` env var) — real
108/// credentials are never baked into the generated repo.
109///
110/// Note: the default credentials contain no URL-reserved characters, so no
111/// percent-encoding is needed. That would change if custom passwords were ever
112/// accepted here.
113fn default_database_url(backend: &str, name: &str, env: &str) -> String {
114    match db_defaults(backend) {
115        Some(d) => {
116            let password = if env == "production" {
117                "CHANGE_ME"
118            } else {
119                d.password
120            };
121            format!(
122                "{}://{}:{}@localhost:{}/{}_{}",
123                d.scheme, d.user, password, d.port, name, env
124            )
125        }
126        None => format!("sqlite://db/{env}.db"),
127    }
128}
129
130pub struct ProjectGenerator;
131
132impl Generator for ProjectGenerator {
133    fn name(&self) -> &str {
134        "new"
135    }
136
137    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
138        let name = args
139            .first()
140            .copied()
141            .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
142
143        let database = args
144            .iter()
145            .find(|a| a.starts_with("--database="))
146            .and_then(|a| a.split_once('=').map(|(_, v)| v))
147            .unwrap_or("sqlite");
148
149        match database {
150            "sqlite" | "postgres" | "mysql" => {}
151            other => {
152                return Err(anyhow::anyhow!(
153                    "Unknown database: {}. Use sqlite, postgres, or mysql.",
154                    other
155                ));
156            }
157        }
158
159        let db_url = default_database_url(database, name, "development");
160        let db_url_test = default_database_url(database, name, "test");
161        let db_url_production = default_database_url(database, name, "production");
162
163        let sqlx_feature = match database {
164            "postgres" => "postgres",
165            "mysql" => "mysql",
166            _ => "sqlite",
167        };
168
169        let ctx = TemplateContext {
170            name,
171            db_url,
172            db_url_test,
173            db_url_production,
174            sqlx_feature,
175        };
176
177        let mut files = Vec::new();
178        collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
179        files.sort_by(|a, b| a.path.cmp(&b.path));
180        Ok(files)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::default_database_url;
187
188    #[test]
189    fn postgres_url_has_default_user_password_and_port() {
190        assert_eq!(
191            default_database_url("postgres", "blog", "development"),
192            "postgres://postgres:postgres@localhost:5432/blog_development"
193        );
194    }
195
196    #[test]
197    fn mysql_url_has_default_user_password_and_port() {
198        assert_eq!(
199            default_database_url("mysql", "store", "test"),
200            "mysql://root:password@localhost:3306/store_test"
201        );
202    }
203
204    #[test]
205    fn production_password_is_a_placeholder() {
206        assert_eq!(
207            default_database_url("postgres", "blog", "production"),
208            "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
209        );
210        assert_eq!(
211            default_database_url("mysql", "store", "production"),
212            "mysql://root:CHANGE_ME@localhost:3306/store_production"
213        );
214    }
215
216    #[test]
217    fn sqlite_stays_a_bare_file_path() {
218        assert_eq!(
219            default_database_url("sqlite", "blog", "development"),
220            "sqlite://db/development.db"
221        );
222    }
223}