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 crate::new_options::{parse_cache, parse_database, parse_jobs, CacheBackend, JobsBackend};
22use doido_core::{anyhow, Result};
23use include_dir::{include_dir, Dir, DirEntry};
24
25/// Embedded filesystem tree merged at compile time from `templates/new`.
26static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
27
28/// Template subtree holding the doido-cable example. Files here are skipped
29/// unless `--cable` is passed.
30const CABLE_TEMPLATE_PREFIX: &str = "app/channels/";
31
32/// `mod channels;` include spliced into `src/main.rs` when `--cable` is passed.
33const CABLE_MODULE_INCLUDE: &str = "\n#[path = \"../app/channels/mod.rs\"]\nmod channels;\n";
34
35/// README section explaining how the generated doido-cable example is wired.
36/// `{doido_name}` is substituted like any other template token.
37const CABLE_README_SECTION: &str = r#"
38## Real-time with doido-cable
39
40This app was generated with `--cable`, so it includes:
41
42- the `doido-cable` (and `async-trait`) dependencies in `Cargo.toml`;
43- an example channel at `app/channels/chat_channel.rs`, registered in
44  `app/channels/mod.rs` and wired into the crate via `mod channels;` in
45  `src/main.rs`.
46
47A channel implements the `Channel` trait — `subscribed`, `unsubscribed`, and
48`received` — and broadcasts to other clients through a shared `Cable` handle over
49a pub/sub backend (`MemoryPubSub` by default; Redis/DB are swappable). See the
50`#[tokio::test]` in `app/channels/chat_channel.rs` for a runnable
51subscribe → broadcast → receive round-trip:
52
53```sh
54cargo test --bin {doido_name} chat
55```
56"#;
57
58
59struct TemplateContext<'a> {
60    name: &'a str,
61    db_url: String,
62    db_url_test: String,
63    db_url_production: String,
64    sqlx_feature: &'a str,
65    cable: bool,
66    doido_dep: String,
67    doido_jobs_dep: String,
68    cache_section: String,
69    jobs_section: String,
70    compose_services: String,
71    compose_depends_on: String,
72    compose_database_url: String,
73    compose_env_extras: String,
74    compose_web_volumes: String,
75}
76
77/// Renders a complete Cargo inline-table dependency for a first-party `doido-*` crate.
78fn doido_dependency(subdir: &str, features: &str) -> String {
79    dependency_spec(
80        crate::TEMPLATE_USE_PATH_DEPS,
81        crate::TEMPLATE_WORKSPACE_PATH,
82        crate::DOIDO_VERSION,
83        subdir,
84        features,
85    )
86}
87
88/// `features` is an optional suffix such as `, features = ["cache-redis"]` (empty when none).
89fn dependency_spec(
90    use_path: bool,
91    workspace_path: &str,
92    version: &str,
93    subdir: &str,
94    features: &str,
95) -> String {
96    let inner = if use_path {
97        format!("path = \"{workspace_path}/{subdir}\"")
98    } else {
99        format!("version = \"{version}\"")
100    };
101    format!("{{ {inner}{features} }}")
102}
103
104fn flag_value<'a>(args: &'a [&str], prefix: &str, default: &'a str) -> &'a str {
105    args.iter()
106        .find(|a| a.starts_with(prefix))
107        .and_then(|a| a.split_once('=').map(|(_, v)| v))
108        .unwrap_or(default)
109}
110
111fn doido_features(cache: CacheBackend) -> String {
112    match cache {
113        CacheBackend::Redis => ", features = [\"cache-redis\"]".to_string(),
114        CacheBackend::Memcache => ", features = [\"cache-memcache\"]".to_string(),
115        CacheBackend::Memory => String::new(),
116    }
117}
118
119fn doido_jobs_features(jobs: JobsBackend) -> String {
120    match jobs {
121        JobsBackend::Db => ", features = [\"jobs-db\"]".to_string(),
122        JobsBackend::Redis => ", features = [\"jobs-redis\"]".to_string(),
123        JobsBackend::Memory => String::new(),
124    }
125}
126
127fn render_cache_section(cache: CacheBackend, name: &str) -> String {
128    match cache {
129        CacheBackend::Memory => "cache:\n  type: memory\n".to_string(),
130        CacheBackend::Redis => format!(
131            "cache:\n  type: redis\n  endpoint: redis://127.0.0.1:6379\n  namespace: {name}\n"
132        ),
133        CacheBackend::Memcache => format!(
134            "cache:\n  type: memcache\n  endpoint: memcache://127.0.0.1:11211\n  namespace: {name}\n"
135        ),
136    }
137}
138
139fn render_jobs_section(jobs: JobsBackend, name: &str) -> String {
140    match jobs {
141        JobsBackend::Memory => "jobs:\n  type: memory\n".to_string(),
142        JobsBackend::Db => {
143            "jobs:\n  type: db\n  queues: [default]\n  concurrency: 5\n".to_string()
144        }
145        JobsBackend::Redis => format!(
146            "jobs:\n  type: redis\n  queues: [default]\n  concurrency: 5\n  redis:\n    url: redis://127.0.0.1:6379\n    namespace: {name}:jobs\n"
147        ),
148    }
149}
150
151fn needs_redis(cable: bool, cache: CacheBackend, jobs: JobsBackend) -> bool {
152    cable || cache == CacheBackend::Redis || jobs == JobsBackend::Redis
153}
154
155fn compose_database_url_for_docker(database: &str, name: &str) -> String {
156    match database {
157        "postgres" => format!("postgres://postgres:postgres@postgres:5432/{name}_development"),
158        "mysql" => format!("mysql://root:password@mysql:3306/{name}_development"),
159        _ => "sqlite://db/development.db".to_string(),
160    }
161}
162
163fn compose_postgres_service(name: &str) -> String {
164    format!(
165        r#"  postgres:
166    image: postgres:18-alpine
167    environment:
168      POSTGRES_USER: postgres
169      POSTGRES_PASSWORD: postgres
170      POSTGRES_DB: {name}_development
171    ports:
172      - "5432:5432"
173    healthcheck:
174      test: ["CMD-SHELL", "pg_isready -U postgres"]
175      interval: 2s
176      timeout: 3s
177      retries: 15"#
178    )
179}
180
181fn compose_mysql_service(name: &str) -> String {
182    format!(
183        r#"  mysql:
184    image: mysql:lts
185    environment:
186      MYSQL_ROOT_PASSWORD: password
187      MYSQL_DATABASE: {name}_development
188    ports:
189      - "3306:3306"
190    healthcheck:
191      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
192      interval: 2s
193      timeout: 3s
194      retries: 15"#
195    )
196}
197
198fn compose_redis_service() -> &'static str {
199    r#"  redis:
200    image: redis:8-alpine
201    ports:
202      - "6379:6379"
203    healthcheck:
204      test: ["CMD", "redis-cli", "ping"]
205      interval: 2s
206      timeout: 3s
207      retries: 15"#
208}
209
210fn compose_memcache_service() -> &'static str {
211    r#"  memcache:
212    image: memcached:1.6-alpine
213    ports:
214      - "11211:11211""#
215}
216
217fn compose_services(database: &str, name: &str, cable: bool, cache: CacheBackend, jobs: JobsBackend) -> String {
218    let mut parts = Vec::new();
219    match database {
220        "postgres" => parts.push(compose_postgres_service(name)),
221        "mysql" => parts.push(compose_mysql_service(name)),
222        _ => {}
223    }
224    if needs_redis(cable, cache, jobs) {
225        parts.push(compose_redis_service().to_string());
226    }
227    if cache == CacheBackend::Memcache {
228        parts.push(compose_memcache_service().to_string());
229    }
230    parts.join("\n\n")
231}
232
233fn compose_depends_on(database: &str, cable: bool, cache: CacheBackend, jobs: JobsBackend) -> String {
234    let mut deps = Vec::new();
235    match database {
236        "postgres" => deps.push("      postgres:\n        condition: service_healthy"),
237        "mysql" => deps.push("      mysql:\n        condition: service_healthy"),
238        _ => {}
239    }
240    if needs_redis(cable, cache, jobs) {
241        deps.push("      redis:\n        condition: service_healthy");
242    }
243    if deps.is_empty() {
244        String::new()
245    } else {
246        format!("    depends_on:\n{}", deps.join("\n"))
247    }
248}
249
250fn compose_env_extras(cache: CacheBackend, jobs: JobsBackend) -> String {
251    let mut lines = Vec::new();
252    match cache {
253        CacheBackend::Redis => lines.push("      CACHE__ENDPOINT: redis://redis:6379"),
254        CacheBackend::Memcache => lines.push("      CACHE__ENDPOINT: memcache://memcache:11211"),
255        CacheBackend::Memory => {}
256    }
257    if jobs == JobsBackend::Redis {
258        lines.push("      JOBS__REDIS__URL: redis://redis:6379");
259    }
260    if lines.is_empty() {
261        String::new()
262    } else {
263        lines.join("\n")
264    }
265}
266
267fn compose_web_volumes(database: &str) -> String {
268    if database == "sqlite" {
269        "      - ./db:/app/db\n".to_string()
270    } else {
271        String::new()
272    }
273}
274
275fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
276    let (cable_deps, cable_module, cable_readme) = if ctx.cable {
277        (
278            format!(
279                "doido-cable = {}\nasync-trait = \"0.1\"\n",
280                doido_dependency("doido-cable", "")
281            ),
282            CABLE_MODULE_INCLUDE.to_string(),
283            CABLE_README_SECTION.replace("{doido_name}", ctx.name),
284        )
285    } else {
286        (String::new(), String::new(), String::new())
287    };
288
289    template
290        .replace("{doido_name}", ctx.name)
291        .replace("{doido_db_url_test}", &ctx.db_url_test)
292        .replace("{doido_db_url_production}", &ctx.db_url_production)
293        .replace("{doido_db_url}", &ctx.db_url)
294        .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
295        .replace("{doido_dep}", &ctx.doido_dep)
296        .replace("{doido_core_dep}", &doido_dependency("doido-core", ""))
297        .replace(
298            "{doido_controller_dep}",
299            &doido_dependency("doido-controller", ""),
300        )
301        .replace("{doido_jobs_dep}", &ctx.doido_jobs_dep)
302        .replace("{doido_mailer_dep}", &doido_dependency("doido-mailer", ""))
303        .replace("{doido_model_dep}", &doido_dependency("doido-model", ""))
304        .replace("{doido_cable_deps}", &cable_deps)
305        .replace("{doido_channels_module}", &cable_module)
306        .replace("{doido_cable_readme}", &cable_readme)
307        .replace("{doido_cache_section}", &ctx.cache_section)
308        .replace("{doido_jobs_section}", &ctx.jobs_section)
309        .replace("{doido_compose_services}", &ctx.compose_services)
310        .replace("{doido_compose_depends_on}", &ctx.compose_depends_on)
311        .replace("{doido_compose_database_url}", &ctx.compose_database_url)
312        .replace("{doido_compose_env_extras}", &ctx.compose_env_extras)
313        .replace("{doido_compose_web_volumes}", &ctx.compose_web_volumes)
314        .replace("{doido_path}", crate::TEMPLATE_WORKSPACE_PATH)
315}
316
317fn collect_from_dir(
318    dir: &Dir<'_>,
319    ctx: &TemplateContext<'_>,
320    app_name: &str,
321    out: &mut Vec<GeneratedFile>,
322) -> Result<()> {
323    for entry in dir.entries() {
324        match entry {
325            DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
326            DirEntry::File(f) => {
327                let relative = f.path();
328                if !ctx.cable && relative.starts_with(CABLE_TEMPLATE_PREFIX) {
329                    continue;
330                }
331                let raw = f.contents_utf8().ok_or_else(|| {
332                    anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
333                })?;
334                let rendered = substitute_template(raw, ctx);
335                let relative = relative.to_string_lossy().replace('\\', "/");
336                let relative = relative.strip_suffix(".template").unwrap_or(&relative);
337                let disk_path = format!("{app_name}/{relative}");
338                out.push(GeneratedFile {
339                    path: disk_path,
340                    content: rendered,
341                });
342            }
343        }
344    }
345    Ok(())
346}
347
348struct DbDefaults {
349    scheme: &'static str,
350    user: &'static str,
351    password: &'static str,
352    port: u16,
353}
354
355fn db_defaults(backend: &str) -> Option<DbDefaults> {
356    match backend {
357        "postgres" => Some(DbDefaults {
358            scheme: "postgres",
359            user: "postgres",
360            password: "postgres",
361            port: 5432,
362        }),
363        "mysql" => Some(DbDefaults {
364            scheme: "mysql",
365            user: "root",
366            password: "password",
367            port: 3306,
368        }),
369        _ => None,
370    }
371}
372
373fn default_database_url(backend: &str, name: &str, env: &str) -> String {
374    match db_defaults(backend) {
375        Some(d) => {
376            let password = if env == "production" {
377                "CHANGE_ME"
378            } else {
379                d.password
380            };
381            format!(
382                "{}://{}:{}@localhost:{}/{}_{}",
383                d.scheme, d.user, password, d.port, name, env
384            )
385        }
386        None => format!("sqlite://db/{env}.db"),
387    }
388}
389
390pub struct ProjectGenerator;
391
392impl Generator for ProjectGenerator {
393    fn name(&self) -> &str {
394        "new"
395    }
396
397    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
398        let name = args
399            .first()
400            .copied()
401            .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
402
403        let database = parse_database(flag_value(args, "--database=", "sqlite"))?;
404        let cache = parse_cache(flag_value(args, "--cache=", "memory"))?;
405        let jobs = parse_jobs(flag_value(args, "--jobs=", "memory"))?;
406        let cable = args.contains(&"--cable");
407
408        let database = database.as_str();
409        let db_url = default_database_url(database, name, "development");
410        let db_url_test = default_database_url(database, name, "test");
411        let db_url_production = default_database_url(database, name, "production");
412
413        let sqlx_feature = match database {
414            "postgres" => "postgres",
415            "mysql" => "mysql",
416            _ => "sqlite",
417        };
418
419        let ctx = TemplateContext {
420            name,
421            db_url,
422            db_url_test,
423            db_url_production,
424            sqlx_feature,
425            cable,
426            doido_dep: doido_dependency("doido", &doido_features(cache)),
427            doido_jobs_dep: doido_dependency("doido-jobs", &doido_jobs_features(jobs)),
428            cache_section: render_cache_section(cache, name),
429            jobs_section: render_jobs_section(jobs, name),
430            compose_services: compose_services(database, name, cable, cache, jobs),
431            compose_depends_on: compose_depends_on(database, cable, cache, jobs),
432            compose_database_url: compose_database_url_for_docker(database, name),
433            compose_env_extras: compose_env_extras(cache, jobs),
434            compose_web_volumes: compose_web_volumes(database),
435        };
436
437        let mut files = Vec::new();
438        collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
439        files.sort_by(|a, b| a.path.cmp(&b.path));
440        Ok(files)
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447    use crate::new_options::{parse_cache, parse_jobs};
448
449    #[test]
450    fn local_builds_emit_path_dependencies() {
451        assert_eq!(
452            dependency_spec(true, "/home/dev/doido", "0.0.6", "doido", ""),
453            "{ path = \"/home/dev/doido/doido\" }"
454        );
455    }
456
457    #[test]
458    fn published_builds_emit_version_dependencies() {
459        assert_eq!(
460            dependency_spec(false, "/irrelevant", "0.0.6", "doido", ""),
461            "{ version = \"0.0.6\" }"
462        );
463    }
464
465    #[test]
466    fn path_dependency_with_features_stays_inside_inline_table() {
467        assert_eq!(
468            dependency_spec(
469                true,
470                "/home/dev/doido",
471                "0.0.6",
472                "doido",
473                ", features = [\"cache-redis\"]",
474            ),
475            "{ path = \"/home/dev/doido/doido\", features = [\"cache-redis\"] }"
476        );
477    }
478
479    #[test]
480    fn published_dependency_with_features_stays_inside_inline_table() {
481        assert_eq!(
482            dependency_spec(
483                false,
484                "/irrelevant",
485                "0.0.6",
486                "doido-jobs",
487                ", features = [\"jobs-redis\"]",
488            ),
489            "{ version = \"0.0.6\", features = [\"jobs-redis\"] }"
490        );
491    }
492
493    fn assert_cargo_toml_parses(cargo_toml: &str) {
494        cargo_toml.parse::<toml::Table>().expect("valid Cargo.toml TOML");
495    }
496
497    fn minimal_cargo_with_doido_line(doido_line: &str) -> String {
498        format!(
499            r#"[package]
500name = "app"
501version = "0.1.0"
502edition = "2021"
503
504[dependencies]
505{doido_line}
506"#
507        )
508    }
509
510    #[test]
511    fn published_cache_redis_line_is_valid_toml() {
512        let line = format!(
513            "doido = {}",
514            dependency_spec(
515                false,
516                "/irrelevant",
517                "0.0.9",
518                "doido",
519                ", features = [\"cache-redis\"]",
520            )
521        );
522        assert!(!line.contains("path ="));
523        assert!(line.contains("version = \"0.0.9\""));
524        assert!(line.contains("cache-redis"));
525        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
526    }
527
528    #[test]
529    fn path_jobs_redis_line_is_valid_toml() {
530        let line = format!(
531            "doido-jobs = {}",
532            dependency_spec(
533                true,
534                "/home/dev/doido",
535                "0.0.9",
536                "doido-jobs",
537                ", features = [\"jobs-redis\"]",
538            )
539        );
540        assert!(line.contains("path = \"/home/dev/doido/doido-jobs\""));
541        assert!(line.contains("jobs-redis"));
542        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
543    }
544
545    #[test]
546    fn postgres_url_has_default_user_password_and_port() {
547        assert_eq!(
548            default_database_url("postgres", "blog", "development"),
549            "postgres://postgres:postgres@localhost:5432/blog_development"
550        );
551    }
552
553    #[test]
554    fn production_password_is_a_placeholder() {
555        assert_eq!(
556            default_database_url("postgres", "blog", "production"),
557            "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
558        );
559    }
560
561    #[test]
562    fn sqlite_stays_a_bare_file_path() {
563        assert_eq!(
564            default_database_url("sqlite", "blog", "development"),
565            "sqlite://db/development.db"
566        );
567    }
568
569    #[test]
570    fn parse_cache_accepts_memcached_alias() {
571        assert_eq!(parse_cache("memcached").unwrap(), CacheBackend::Memcache);
572    }
573
574    #[test]
575    fn parse_jobs_accepts_database_alias() {
576        assert_eq!(parse_jobs("database").unwrap(), JobsBackend::Db);
577    }
578
579    #[test]
580    fn compose_includes_redis_for_cache_redis() {
581        let svc = compose_services("sqlite", "app", false, CacheBackend::Redis, JobsBackend::Memory);
582        assert!(svc.contains("redis:8-alpine"));
583        assert!(!svc.contains("postgres:"));
584    }
585
586    #[test]
587    fn compose_includes_memcache_for_cache_memcache() {
588        let svc = compose_services("sqlite", "app", false, CacheBackend::Memcache, JobsBackend::Memory);
589        assert!(svc.contains("memcached:1.6-alpine"));
590        assert!(!svc.contains("redis:"));
591    }
592
593    #[test]
594    fn compose_deduplicates_redis_when_cable_and_jobs_redis() {
595        let svc = compose_services("sqlite", "app", true, CacheBackend::Memory, JobsBackend::Redis);
596        assert_eq!(svc.matches("image: redis:").count(), 1);
597    }
598
599    #[test]
600    fn compose_database_url_uses_docker_hostnames() {
601        assert_eq!(
602            compose_database_url_for_docker("postgres", "blog"),
603            "postgres://postgres:postgres@postgres:5432/blog_development"
604        );
605    }
606}