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//! The optional doido-cable example lives inside this same template. Its channel
10//! files sit under `templates/new/app/channels/` (skipped unless `--cable` is
11//! passed), and the `{doido_cable_deps}` / `{doido_channels_module}` /
12//! `{doido_cable_readme}` placeholders in `Cargo.toml`, `src/main.rs`, and
13//! `README.md` render to their wiring when `--cable` is set, or to nothing
14//! otherwise (see [`substitute_template`]).
15//!
16//! Template files carrying a trailing `.template` suffix (e.g. `Cargo.toml.template`)
17//! have the suffix stripped on output; the suffix keeps `cargo package` from treating
18//! `templates/new/` as a nested crate and excluding it from the published tarball.
19
20use crate::generator::{GeneratedFile, Generator};
21use doido_core::{anyhow, Result};
22use include_dir::{include_dir, Dir, DirEntry};
23
24/// Embedded filesystem tree merged at compile time from `templates/new`.
25static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
26
27/// Template subtree holding the doido-cable example. Files here are skipped
28/// unless `--cable` is passed.
29const CABLE_TEMPLATE_PREFIX: &str = "app/channels/";
30
31/// `mod channels;` include spliced into `src/main.rs` when `--cable` is passed.
32const CABLE_MODULE_INCLUDE: &str = "\n#[path = \"../app/channels/mod.rs\"]\nmod channels;\n";
33
34/// README section explaining how the generated doido-cable example is wired.
35/// `{doido_name}` is substituted like any other template token.
36const CABLE_README_SECTION: &str = r#"
37## Real-time with doido-cable
38
39This app was generated with `--cable`, so it includes:
40
41- the `doido-cable` (and `async-trait`) dependencies in `Cargo.toml`;
42- an example channel at `app/channels/chat_channel.rs`, registered in
43  `app/channels/mod.rs` and wired into the crate via `mod channels;` in
44  `src/main.rs`.
45
46A channel implements the `Channel` trait — `subscribed`, `unsubscribed`, and
47`received` — and broadcasts to other clients through a shared `Cable` handle over
48a pub/sub backend (`MemoryPubSub` by default; Redis/DB are swappable). See the
49`#[tokio::test]` in `app/channels/chat_channel.rs` for a runnable
50subscribe → broadcast → receive round-trip:
51
52```sh
53cargo test --bin {doido_name} chat
54```
55"#;
56
57struct TemplateContext<'a> {
58    name: &'a str,
59    db_url: String,
60    db_url_test: String,
61    db_url_production: String,
62    sqlx_feature: &'a str,
63    /// Whether to include the doido-cable example (the `--cable` flag).
64    cable: bool,
65}
66
67/// Renders the Cargo dependency source for a first-party `doido-*` crate.
68///
69/// `subdir` is the crate's directory under the workspace root (e.g. `doido`,
70/// `doido-controller`). In a local workspace build this returns a `path`
71/// dependency so a freshly generated app compiles against the in-tree framework;
72/// in a published build (siblings absent) it returns a `version` dependency that
73/// resolves the matching release from crates.io.
74fn doido_dependency(subdir: &str) -> String {
75    dependency_spec(
76        crate::TEMPLATE_USE_PATH_DEPS,
77        crate::TEMPLATE_WORKSPACE_PATH,
78        crate::DOIDO_VERSION,
79        subdir,
80    )
81}
82
83/// Pure core of [`doido_dependency`]: builds a `path` or `version` dependency
84/// source string from the given inputs. Split out so both branches are unit
85/// testable without depending on how this crate was built.
86fn dependency_spec(use_path: bool, workspace_path: &str, version: &str, subdir: &str) -> String {
87    if use_path {
88        format!("{{ path = \"{workspace_path}/{subdir}\" }}")
89    } else {
90        format!("\"{version}\"")
91    }
92}
93
94fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
95    // doido-cable wiring: rendered when `--cable` is set, empty otherwise. The
96    // README section is name-substituted up front since it carries `{doido_name}`.
97    let (cable_deps, cable_module, cable_readme) = if ctx.cable {
98        (
99            format!(
100                "doido-cable = {}\nasync-trait = \"0.1\"\n",
101                doido_dependency("doido-cable")
102            ),
103            CABLE_MODULE_INCLUDE.to_string(),
104            CABLE_README_SECTION.replace("{doido_name}", ctx.name),
105        )
106    } else {
107        (String::new(), String::new(), String::new())
108    };
109
110    template
111        .replace("{doido_name}", ctx.name)
112        .replace("{doido_db_url_test}", &ctx.db_url_test)
113        .replace("{doido_db_url_production}", &ctx.db_url_production)
114        .replace("{doido_db_url}", &ctx.db_url)
115        .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
116        .replace("{doido_dep}", &doido_dependency("doido"))
117        .replace("{doido_core_dep}", &doido_dependency("doido-core"))
118        .replace(
119            "{doido_controller_dep}",
120            &doido_dependency("doido-controller"),
121        )
122        .replace("{doido_jobs_dep}", &doido_dependency("doido-jobs"))
123        .replace("{doido_mailer_dep}", &doido_dependency("doido-mailer"))
124        .replace("{doido_model_dep}", &doido_dependency("doido-model"))
125        .replace("{doido_cable_deps}", &cable_deps)
126        .replace("{doido_channels_module}", &cable_module)
127        .replace("{doido_cable_readme}", &cable_readme)
128        .replace("{doido_path}", crate::TEMPLATE_WORKSPACE_PATH)
129}
130
131fn collect_from_dir(
132    dir: &Dir<'_>,
133    ctx: &TemplateContext<'_>,
134    app_name: &str,
135    out: &mut Vec<GeneratedFile>,
136) -> Result<()> {
137    for entry in dir.entries() {
138        match entry {
139            DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
140            DirEntry::File(f) => {
141                // `include_dir` stores paths relative to the embedded root (`templates/new/`)
142                // for every file, including nested paths like `src/main.rs`.
143                let relative = f.path();
144                // The doido-cable example lives under `app/channels/`; skip it
145                // unless `--cable` opted the app in.
146                if !ctx.cable && relative.starts_with(CABLE_TEMPLATE_PREFIX) {
147                    continue;
148                }
149                let raw = f.contents_utf8().ok_or_else(|| {
150                    anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
151                })?;
152                let rendered = substitute_template(raw, ctx);
153                // Template manifests are stored with a trailing `.template` suffix
154                // (e.g. `Cargo.toml.template`) so `cargo package` doesn't mistake
155                // `templates/app/` for a nested crate and drop it from the tarball.
156                // Strip the suffix when writing the generated app to disk.
157                let relative = relative.to_string_lossy().replace('\\', "/");
158                let relative = relative.strip_suffix(".template").unwrap_or(&relative);
159                let disk_path = format!("{app_name}/{relative}");
160                out.push(GeneratedFile {
161                    path: disk_path,
162                    content: rendered,
163                });
164            }
165        }
166    }
167    Ok(())
168}
169
170/// Default local connection parameters for a server backend.
171struct DbDefaults {
172    /// URL scheme (`postgres` / `mysql`).
173    scheme: &'static str,
174    /// Default superuser for a local install.
175    user: &'static str,
176    /// Default development/test password (placeholder in production).
177    password: &'static str,
178    /// Default listening port.
179    port: u16,
180}
181
182/// Returns the default connection parameters for `postgres`/`mysql`, or `None`
183/// for file-based backends (sqlite) that carry no user/host/port.
184fn db_defaults(backend: &str) -> Option<DbDefaults> {
185    match backend {
186        "postgres" => Some(DbDefaults {
187            scheme: "postgres",
188            user: "postgres",
189            password: "postgres",
190            port: 5432,
191        }),
192        "mysql" => Some(DbDefaults {
193            scheme: "mysql",
194            user: "root",
195            password: "password",
196            port: 3306,
197        }),
198        _ => None,
199    }
200}
201
202/// Builds the `database.url` for one environment of a generated app.
203///
204/// Server backends (postgres/mysql) include the default user, password, host
205/// and port so the generated config is close to a working local setup, e.g.
206/// `postgres://postgres:postgres@localhost:5432/blog_development`. sqlite uses a
207/// bare file path. In **production** the password is a `CHANGE_ME` placeholder
208/// that must be overridden (e.g. via the `DATABASE_URL` env var) — real
209/// credentials are never baked into the generated repo.
210///
211/// Note: the default credentials contain no URL-reserved characters, so no
212/// percent-encoding is needed. That would change if custom passwords were ever
213/// accepted here.
214fn default_database_url(backend: &str, name: &str, env: &str) -> String {
215    match db_defaults(backend) {
216        Some(d) => {
217            let password = if env == "production" {
218                "CHANGE_ME"
219            } else {
220                d.password
221            };
222            format!(
223                "{}://{}:{}@localhost:{}/{}_{}",
224                d.scheme, d.user, password, d.port, name, env
225            )
226        }
227        None => format!("sqlite://db/{env}.db"),
228    }
229}
230
231pub struct ProjectGenerator;
232
233impl Generator for ProjectGenerator {
234    fn name(&self) -> &str {
235        "new"
236    }
237
238    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
239        let name = args
240            .first()
241            .copied()
242            .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
243
244        let database = args
245            .iter()
246            .find(|a| a.starts_with("--database="))
247            .and_then(|a| a.split_once('=').map(|(_, v)| v))
248            .unwrap_or("sqlite");
249
250        match database {
251            "sqlite" | "postgres" | "mysql" => {}
252            other => {
253                return Err(anyhow::anyhow!(
254                    "Unknown database: {}. Use sqlite, postgres, or mysql.",
255                    other
256                ));
257            }
258        }
259
260        let db_url = default_database_url(database, name, "development");
261        let db_url_test = default_database_url(database, name, "test");
262        let db_url_production = default_database_url(database, name, "production");
263
264        let sqlx_feature = match database {
265            "postgres" => "postgres",
266            "mysql" => "mysql",
267            _ => "sqlite",
268        };
269
270        // `--cable` opts the new app into the doido-cable example (channel files
271        // plus the dependency/module/README wiring it needs to compile).
272        let cable = args.contains(&"--cable");
273
274        let ctx = TemplateContext {
275            name,
276            db_url,
277            db_url_test,
278            db_url_production,
279            sqlx_feature,
280            cable,
281        };
282
283        let mut files = Vec::new();
284        collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
285        files.sort_by(|a, b| a.path.cmp(&b.path));
286        Ok(files)
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::{default_database_url, dependency_spec};
293
294    #[test]
295    fn local_builds_emit_path_dependencies() {
296        assert_eq!(
297            dependency_spec(true, "/home/dev/doido", "0.0.6", "doido"),
298            "{ path = \"/home/dev/doido/doido\" }"
299        );
300        assert_eq!(
301            dependency_spec(true, "/home/dev/doido", "0.0.6", "doido-controller"),
302            "{ path = \"/home/dev/doido/doido-controller\" }"
303        );
304    }
305
306    #[test]
307    fn published_builds_emit_version_dependencies() {
308        // Once published, the sibling crates are gone, so apps resolve the
309        // matching release from crates.io — the workspace path is irrelevant.
310        assert_eq!(
311            dependency_spec(false, "/irrelevant", "0.0.6", "doido"),
312            "\"0.0.6\""
313        );
314        assert_eq!(
315            dependency_spec(false, "/irrelevant", "1.2.3", "doido-model"),
316            "\"1.2.3\""
317        );
318    }
319
320    #[test]
321    fn postgres_url_has_default_user_password_and_port() {
322        assert_eq!(
323            default_database_url("postgres", "blog", "development"),
324            "postgres://postgres:postgres@localhost:5432/blog_development"
325        );
326    }
327
328    #[test]
329    fn mysql_url_has_default_user_password_and_port() {
330        assert_eq!(
331            default_database_url("mysql", "store", "test"),
332            "mysql://root:password@localhost:3306/store_test"
333        );
334    }
335
336    #[test]
337    fn production_password_is_a_placeholder() {
338        assert_eq!(
339            default_database_url("postgres", "blog", "production"),
340            "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
341        );
342        assert_eq!(
343            default_database_url("mysql", "store", "production"),
344            "mysql://root:CHANGE_ME@localhost:3306/store_production"
345        );
346    }
347
348    #[test]
349    fn sqlite_stays_a_bare_file_path() {
350        assert_eq!(
351            default_database_url("sqlite", "blog", "development"),
352            "sqlite://db/development.db"
353        );
354    }
355}