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