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}` / `{doido_migration_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::generators::bootstrap_migrations::{apply_bootstrap_migrations, storage_config_section};
23use crate::new_options::{parse_cache, parse_database, parse_jobs, CacheBackend, JobsBackend};
24use doido_core::{anyhow, Result};
25use include_dir::{include_dir, Dir, DirEntry};
26
27/// Embedded filesystem tree merged at compile time from `templates/new`.
28static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
29
30/// Template subtree holding the doido-cable example. Files here are skipped
31/// unless `--cable` is passed.
32const CABLE_TEMPLATE_PREFIX: &str = "app/channels/";
33
34/// `mod channels;` include spliced into `src/main.rs` when `--cable` is passed.
35const CABLE_MODULE_INCLUDE: &str = "\n#[path = \"../app/channels/mod.rs\"]\nmod channels;\n";
36
37/// README section explaining how the generated doido-cable example is wired.
38/// `{doido_name}` is substituted like any other template token.
39const CABLE_README_SECTION: &str = r#"
40## Real-time with doido-cable
41
42This app was generated with `--cable`, so it includes:
43
44- the `doido-cable` (and `async-trait`) dependencies in `Cargo.toml`;
45- an example channel at `app/channels/chat_channel.rs`, registered in
46  `app/channels/mod.rs` and wired into the crate via `mod channels;` in
47  `src/main.rs`.
48
49A channel implements the `Channel` trait — `subscribed`, `unsubscribed`, and
50`received` — and broadcasts to other clients through a shared `Cable` handle over
51a pub/sub backend (`MemoryPubSub` by default; Redis/DB are swappable). See the
52`#[tokio::test]` in `app/channels/chat_channel.rs` for a runnable
53subscribe → broadcast → receive round-trip:
54
55```sh
56cargo test --bin {doido_name} chat
57```
58"#;
59
60struct TemplateContext<'a> {
61    name: &'a str,
62    db_url: String,
63    db_url_test: String,
64    db_url_production: String,
65    sqlx_feature: &'a str,
66    cable: bool,
67    auth: bool,
68    api: bool,
69    dep_mode: DependencyMode,
70    doido_dep: String,
71    doido_migration_dep: String,
72    doido_seed_dep: String,
73    doido_jobs_dep: String,
74    doido_model_dep: String,
75    cache_section: String,
76    jobs_section: String,
77    storage_section: String,
78    compose_services: String,
79    compose_depends_on: String,
80    compose_database_url: String,
81    compose_env_extras: String,
82    compose_web_volumes: String,
83}
84
85/// Renders a complete Cargo inline-table dependency for a first-party `doido-*` crate.
86fn doido_dependency(mode: &DependencyMode, subdir: &str, features: &str) -> String {
87    dependency_spec(
88        mode.use_path,
89        &mode.workspace_path,
90        mode.version,
91        subdir,
92        features,
93    )
94}
95
96/// `features` is an optional suffix such as `, features = ["cache-redis"]` (empty when none).
97fn dependency_spec(
98    use_path: bool,
99    workspace_path: &str,
100    version: &str,
101    subdir: &str,
102    features: &str,
103) -> String {
104    let inner = if use_path {
105        format!("path = \"{workspace_path}/{subdir}\"")
106    } else {
107        format!("version = \"{version}\"")
108    };
109    format!("{{ {inner}{features} }}")
110}
111
112fn flag_value<'a>(args: &'a [&str], prefix: &str, default: &'a str) -> &'a str {
113    args.iter()
114        .find(|a| a.starts_with(prefix))
115        .and_then(|a| a.split_once('=').map(|(_, v)| v))
116        .unwrap_or(default)
117}
118
119fn doido_features(cache: CacheBackend, database: &str, auth: bool) -> String {
120    let mut feats = vec![format!("\"{database}\"")];
121    if auth {
122        feats.push("\"auth\"".to_string());
123    }
124    match cache {
125        CacheBackend::Redis => feats.push("\"cache-redis\"".to_string()),
126        CacheBackend::Memcache => feats.push("\"cache-memcache\"".to_string()),
127        CacheBackend::Memory => {}
128    }
129    format!(
130        ", default-features = false, features = [{}]",
131        feats.join(", ")
132    )
133}
134
135fn doido_jobs_features(jobs: JobsBackend, database: &str) -> String {
136    match jobs {
137        JobsBackend::Db => format!(", features = [\"jobs-db\", \"{database}\"]"),
138        JobsBackend::Redis => ", features = [\"jobs-redis\"]".to_string(),
139        JobsBackend::Memory => String::new(),
140    }
141}
142
143fn doido_migration_features(database: &str) -> String {
144    format!(", default-features = false, features = [\"{database}\", \"cli\"]")
145}
146
147fn doido_seed_features(database: &str) -> String {
148    format!(", default-features = false, features = [\"{database}\"]")
149}
150
151fn doido_model_features(database: &str) -> String {
152    match database {
153        "postgres" => ", features = [\"postgres\"]".to_string(),
154        "mysql" => ", features = [\"mysql\"]".to_string(),
155        _ => ", features = [\"sqlite\"]".to_string(),
156    }
157}
158
159fn render_cache_section(cache: CacheBackend, name: &str) -> String {
160    match cache {
161        CacheBackend::Memory => "cache:\n  type: memory\n".to_string(),
162        CacheBackend::Redis => format!(
163            "cache:\n  type: redis\n  endpoint: redis://127.0.0.1:6379\n  namespace: {name}\n"
164        ),
165        CacheBackend::Memcache => format!(
166            "cache:\n  type: memcache\n  endpoint: memcache://127.0.0.1:11211\n  namespace: {name}\n"
167        ),
168    }
169}
170
171fn render_jobs_section(jobs: JobsBackend, name: &str) -> String {
172    match jobs {
173        JobsBackend::Memory => "jobs:\n  type: memory\n".to_string(),
174        JobsBackend::Db => {
175            "jobs:\n  type: db\n  queues: [default]\n  concurrency: 5\n".to_string()
176        }
177        JobsBackend::Redis => format!(
178            "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"
179        ),
180    }
181}
182
183/// Development SMTP — Mailpit on localhost when running `cargo doido server`.
184const MAILER_DEV_SECTION: &str = "mailer:\n  type: smtp\n  smtp:\n    address: localhost:1025\n";
185
186fn compose_mailpit_service() -> &'static str {
187    r#"  mailpit:
188    image: axllent/mailpit:latest
189    ports:
190      - "1025:1025"
191      - "8025:8025""#
192}
193
194fn needs_redis(cable: bool, cache: CacheBackend, jobs: JobsBackend) -> bool {
195    cable || cache == CacheBackend::Redis || jobs == JobsBackend::Redis
196}
197
198fn compose_database_url_for_docker(database: &str, name: &str) -> String {
199    match database {
200        "postgres" => format!("postgres://postgres:postgres@postgres:5432/{name}_development"),
201        "mysql" => format!("mysql://root:password@mysql:3306/{name}_development"),
202        _ => "sqlite://db/development.db".to_string(),
203    }
204}
205
206fn compose_postgres_service(name: &str) -> String {
207    format!(
208        r#"  postgres:
209    image: postgres:18-alpine
210    environment:
211      POSTGRES_USER: postgres
212      POSTGRES_PASSWORD: postgres
213      POSTGRES_DB: {name}_development
214    ports:
215      - "5432:5432"
216    healthcheck:
217      test: ["CMD-SHELL", "pg_isready -U postgres"]
218      interval: 2s
219      timeout: 3s
220      retries: 15"#
221    )
222}
223
224fn compose_mysql_service(name: &str) -> String {
225    format!(
226        r#"  mysql:
227    image: mysql:lts
228    environment:
229      MYSQL_ROOT_PASSWORD: password
230      MYSQL_DATABASE: {name}_development
231    ports:
232      - "3306:3306"
233    healthcheck:
234      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
235      interval: 2s
236      timeout: 3s
237      retries: 15"#
238    )
239}
240
241fn compose_redis_service() -> &'static str {
242    r#"  redis:
243    image: redis:8-alpine
244    ports:
245      - "6379:6379"
246    healthcheck:
247      test: ["CMD", "redis-cli", "ping"]
248      interval: 2s
249      timeout: 3s
250      retries: 15"#
251}
252
253fn compose_memcache_service() -> &'static str {
254    r#"  memcache:
255    image: memcached:1.6-alpine
256    ports:
257      - "11211:11211""#
258}
259
260fn compose_services(
261    database: &str,
262    name: &str,
263    cable: bool,
264    cache: CacheBackend,
265    jobs: JobsBackend,
266) -> String {
267    let mut parts = Vec::new();
268    match database {
269        "postgres" => parts.push(compose_postgres_service(name)),
270        "mysql" => parts.push(compose_mysql_service(name)),
271        _ => {}
272    }
273    if needs_redis(cable, cache, jobs) {
274        parts.push(compose_redis_service().to_string());
275    }
276    if cache == CacheBackend::Memcache {
277        parts.push(compose_memcache_service().to_string());
278    }
279    parts.push(compose_mailpit_service().to_string());
280    parts.join("\n\n")
281}
282
283fn compose_depends_on(
284    database: &str,
285    cable: bool,
286    cache: CacheBackend,
287    jobs: JobsBackend,
288) -> String {
289    let mut deps = vec!["      mailpit:\n        condition: service_started"];
290    match database {
291        "postgres" => deps.push("      postgres:\n        condition: service_healthy"),
292        "mysql" => deps.push("      mysql:\n        condition: service_healthy"),
293        _ => {}
294    }
295    if needs_redis(cable, cache, jobs) {
296        deps.push("      redis:\n        condition: service_healthy");
297    }
298    format!("    depends_on:\n{}", deps.join("\n"))
299}
300
301fn compose_env_extras(cache: CacheBackend, jobs: JobsBackend) -> String {
302    let mut lines = vec![
303        "      MAILER__TYPE: smtp",
304        "      MAILER__SMTP__ADDRESS: mailpit:1025",
305    ];
306    match cache {
307        CacheBackend::Redis => lines.push("      CACHE__ENDPOINT: redis://redis:6379"),
308        CacheBackend::Memcache => lines.push("      CACHE__ENDPOINT: memcache://memcache:11211"),
309        CacheBackend::Memory => {}
310    }
311    if jobs == JobsBackend::Redis {
312        lines.push("      JOBS__REDIS__URL: redis://redis:6379");
313    }
314    if lines.is_empty() {
315        String::new()
316    } else {
317        lines.join("\n")
318    }
319}
320
321fn compose_web_volumes(database: &str) -> String {
322    if database == "sqlite" {
323        "      - ./db:/app/db\n".to_string()
324    } else {
325        String::new()
326    }
327}
328
329fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
330    let (cable_deps, cable_module, cable_readme) = if ctx.cable {
331        (
332            format!(
333                "doido-cable = {}\nasync-trait = \"0.1\"\n",
334                doido_dependency(&ctx.dep_mode, "doido-cable", "")
335            ),
336            CABLE_MODULE_INCLUDE.to_string(),
337            CABLE_README_SECTION.replace("{doido_name}", ctx.name),
338        )
339    } else {
340        (String::new(), String::new(), String::new())
341    };
342
343    let doido_auth_deps = if ctx.auth {
344        format!(
345            "doido-auth = {}\nchrono = {{ version = \"0.4\", features = [\"clock\"] }}\n",
346            doido_dependency(&ctx.dep_mode, "doido-auth", "")
347        )
348    } else {
349        String::new()
350    };
351
352    template
353        .replace("{doido_name}", ctx.name)
354        .replace("{doido_db_url_test}", &ctx.db_url_test)
355        .replace("{doido_db_url_production}", &ctx.db_url_production)
356        .replace("{doido_db_url}", &ctx.db_url)
357        .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
358        .replace("{doido_dep}", &ctx.doido_dep)
359        .replace("{doido_migration_dep}", &ctx.doido_migration_dep)
360        .replace("{doido_seed_dep}", &ctx.doido_seed_dep)
361        .replace(
362            "{doido_core_dep}",
363            &doido_dependency(&ctx.dep_mode, "doido-core", ""),
364        )
365        .replace(
366            "{doido_controller_dep}",
367            &doido_dependency(
368                &ctx.dep_mode,
369                "doido-controller",
370                &doido_model_features(ctx.sqlx_feature),
371            ),
372        )
373        .replace("{doido_jobs_dep}", &ctx.doido_jobs_dep)
374        .replace(
375            "{doido_mailer_dep}",
376            &doido_dependency(&ctx.dep_mode, "doido-mailer", ""),
377        )
378        .replace("{doido_model_dep}", &ctx.doido_model_dep)
379        .replace("{doido_cable_deps}", &cable_deps)
380        .replace("{doido_auth_deps}", &doido_auth_deps)
381        .replace(
382            "{doido_api_only}",
383            if ctx.api { "\napi_only = true" } else { "" },
384        )
385        .replace("{doido_channels_module}", &cable_module)
386        .replace("{doido_cable_readme}", &cable_readme)
387        .replace("{doido_cache_section}", &ctx.cache_section)
388        .replace("{doido_jobs_section}", &ctx.jobs_section)
389        .replace("{doido_storage_section}", &ctx.storage_section)
390        .replace("{doido_mailer_section}", MAILER_DEV_SECTION)
391        .replace("{doido_compose_services}", &ctx.compose_services)
392        .replace("{doido_compose_depends_on}", &ctx.compose_depends_on)
393        .replace("{doido_compose_database_url}", &ctx.compose_database_url)
394        .replace("{doido_compose_env_extras}", &ctx.compose_env_extras)
395        .replace("{doido_compose_web_volumes}", &ctx.compose_web_volumes)
396        .replace("{doido_path}", &ctx.dep_mode.workspace_path)
397}
398
399fn collect_from_dir(
400    dir: &Dir<'_>,
401    ctx: &TemplateContext<'_>,
402    app_name: &str,
403    out: &mut Vec<GeneratedFile>,
404) -> Result<()> {
405    for entry in dir.entries() {
406        match entry {
407            DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
408            DirEntry::File(f) => {
409                let relative = f.path();
410                if !ctx.cable && relative.starts_with(CABLE_TEMPLATE_PREFIX) {
411                    continue;
412                }
413                let raw = f.contents_utf8().ok_or_else(|| {
414                    anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
415                })?;
416                let rendered = substitute_template(raw, ctx);
417                let relative = relative.to_string_lossy().replace('\\', "/");
418                let relative = relative.strip_suffix(".template").unwrap_or(&relative);
419                let disk_path = format!("{app_name}/{relative}");
420                out.push(GeneratedFile {
421                    path: disk_path,
422                    content: rendered,
423                });
424            }
425        }
426    }
427    Ok(())
428}
429
430struct DbDefaults {
431    scheme: &'static str,
432    user: &'static str,
433    password: &'static str,
434    port: u16,
435}
436
437fn db_defaults(backend: &str) -> Option<DbDefaults> {
438    match backend {
439        "postgres" => Some(DbDefaults {
440            scheme: "postgres",
441            user: "postgres",
442            password: "postgres",
443            port: 5432,
444        }),
445        "mysql" => Some(DbDefaults {
446            scheme: "mysql",
447            user: "root",
448            password: "password",
449            port: 3306,
450        }),
451        _ => None,
452    }
453}
454
455fn default_database_url(backend: &str, name: &str, env: &str) -> String {
456    match db_defaults(backend) {
457        Some(d) => {
458            let password = if env == "production" {
459                "CHANGE_ME"
460            } else {
461                d.password
462            };
463            format!(
464                "{}://{}:{}@localhost:{}/{}_{}",
465                d.scheme, d.user, password, d.port, name, env
466            )
467        }
468        None => format!("sqlite://db/{env}.db"),
469    }
470}
471
472pub struct ProjectGenerator;
473
474impl Generator for ProjectGenerator {
475    fn name(&self) -> &str {
476        "new"
477    }
478
479    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
480        let name = args
481            .first()
482            .copied()
483            .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
484
485        let database = parse_database(flag_value(args, "--database=", "sqlite"))?;
486        let cache = parse_cache(flag_value(args, "--cache=", "memory"))?;
487        let jobs = parse_jobs(flag_value(args, "--jobs=", "memory"))?;
488        let cable = args.contains(&"--cable");
489        let auth = args.contains(&"--auth");
490        let api = args.contains(&"--api");
491
492        let database = database.as_str();
493        let db_url = default_database_url(database, name, "development");
494        let db_url_test = default_database_url(database, name, "test");
495        let db_url_production = default_database_url(database, name, "production");
496
497        let sqlx_feature = match database {
498            "postgres" => "postgres",
499            "mysql" => "mysql",
500            _ => "sqlite",
501        };
502
503        let dep_mode = DependencyMode::resolve();
504
505        let ctx = TemplateContext {
506            name,
507            db_url,
508            db_url_test,
509            db_url_production,
510            sqlx_feature,
511            cable,
512            auth,
513            api,
514            doido_dep: doido_dependency(&dep_mode, "doido", &doido_features(cache, database, auth)),
515            doido_migration_dep: doido_dependency(
516                &dep_mode,
517                "doido",
518                &doido_migration_features(database),
519            ),
520            doido_seed_dep: doido_dependency(&dep_mode, "doido", &doido_seed_features(database)),
521            doido_jobs_dep: doido_dependency(
522                &dep_mode,
523                "doido-jobs",
524                &doido_jobs_features(jobs, database),
525            ),
526            doido_model_dep: doido_dependency(
527                &dep_mode,
528                "doido-model",
529                &doido_model_features(database),
530            ),
531            dep_mode,
532            cache_section: render_cache_section(cache, name),
533            jobs_section: render_jobs_section(jobs, name),
534            storage_section: storage_config_section("local"),
535            compose_services: compose_services(database, name, cable, cache, jobs),
536            compose_depends_on: compose_depends_on(database, cable, cache, jobs),
537            compose_database_url: compose_database_url_for_docker(database, name),
538            compose_env_extras: compose_env_extras(cache, jobs),
539            compose_web_volumes: compose_web_volumes(database),
540        };
541
542        let mut files = Vec::new();
543        collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
544
545        if let Some(lib) = files
546            .iter_mut()
547            .find(|f| f.path.ends_with("db/migration/src/lib.rs"))
548        {
549            let (updated, migrations) =
550                apply_bootstrap_migrations(&lib.content, jobs == JobsBackend::Db);
551            lib.content = updated;
552            for (module, content) in migrations {
553                files.push(GeneratedFile {
554                    path: format!("{name}/db/migration/src/{module}.rs"),
555                    content,
556                });
557            }
558        }
559
560        files.sort_by(|a, b| a.path.cmp(&b.path));
561        Ok(files)
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::new_options::{parse_cache, parse_jobs, JobsBackend};
569
570    #[test]
571    fn local_builds_emit_path_dependencies() {
572        assert_eq!(
573            dependency_spec(true, "/home/dev/doido", "0.0.6", "doido", ""),
574            "{ path = \"/home/dev/doido/doido\" }"
575        );
576    }
577
578    #[test]
579    fn published_builds_emit_version_dependencies() {
580        assert_eq!(
581            dependency_spec(false, "/irrelevant", "0.0.6", "doido", ""),
582            "{ version = \"0.0.6\" }"
583        );
584    }
585
586    #[test]
587    fn path_dependency_with_features_stays_inside_inline_table() {
588        assert_eq!(
589            dependency_spec(
590                true,
591                "/home/dev/doido",
592                "0.0.6",
593                "doido",
594                ", features = [\"cache-redis\"]",
595            ),
596            "{ path = \"/home/dev/doido/doido\", features = [\"cache-redis\"] }"
597        );
598    }
599
600    #[test]
601    fn published_dependency_with_features_stays_inside_inline_table() {
602        assert_eq!(
603            dependency_spec(
604                false,
605                "/irrelevant",
606                "0.0.6",
607                "doido-jobs",
608                ", features = [\"jobs-redis\"]",
609            ),
610            "{ version = \"0.0.6\", features = [\"jobs-redis\"] }"
611        );
612    }
613
614    fn assert_cargo_toml_parses(cargo_toml: &str) {
615        cargo_toml
616            .parse::<toml::Table>()
617            .expect("valid Cargo.toml TOML");
618    }
619
620    fn minimal_cargo_with_doido_line(doido_line: &str) -> String {
621        format!(
622            r#"[package]
623name = "app"
624version = "0.1.0"
625edition = "2021"
626
627[dependencies]
628{doido_line}
629"#
630        )
631    }
632
633    #[test]
634    fn doido_features_include_database_and_cache() {
635        assert_eq!(
636            doido_features(CacheBackend::Memory, "postgres", false),
637            ", default-features = false, features = [\"postgres\"]"
638        );
639        assert_eq!(
640            doido_features(CacheBackend::Redis, "sqlite", false),
641            ", default-features = false, features = [\"sqlite\", \"cache-redis\"]"
642        );
643        assert_eq!(
644            doido_features(CacheBackend::Memory, "sqlite", true),
645            ", default-features = false, features = [\"sqlite\", \"auth\"]"
646        );
647    }
648
649    #[test]
650    fn published_cache_redis_line_is_valid_toml() {
651        let line = format!(
652            "doido = {}",
653            dependency_spec(
654                false,
655                "/irrelevant",
656                "0.0.9",
657                "doido",
658                &doido_features(CacheBackend::Redis, "sqlite", false),
659            )
660        );
661        assert!(!line.contains("path ="));
662        assert!(line.contains("version = \"0.0.9\""));
663        assert!(line.contains("cache-redis"));
664        assert!(line.contains("sqlite"));
665        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
666    }
667
668    #[test]
669    fn doido_migration_features_include_database_and_cli() {
670        assert_eq!(
671            doido_migration_features("sqlite"),
672            ", default-features = false, features = [\"sqlite\", \"cli\"]"
673        );
674        assert_eq!(
675            doido_migration_features("postgres"),
676            ", default-features = false, features = [\"postgres\", \"cli\"]"
677        );
678    }
679
680    #[test]
681    fn doido_seed_features_include_database_only() {
682        assert_eq!(
683            doido_seed_features("sqlite"),
684            ", default-features = false, features = [\"sqlite\"]"
685        );
686        assert_eq!(
687            doido_seed_features("postgres"),
688            ", default-features = false, features = [\"postgres\"]"
689        );
690    }
691
692    #[test]
693    fn doido_model_features_match_database_backend() {
694        assert_eq!(doido_model_features("sqlite"), ", features = [\"sqlite\"]");
695        assert_eq!(
696            doido_model_features("postgres"),
697            ", features = [\"postgres\"]"
698        );
699        assert_eq!(doido_model_features("mysql"), ", features = [\"mysql\"]");
700    }
701
702    #[test]
703    fn doido_jobs_db_features_include_database_driver() {
704        assert_eq!(
705            doido_jobs_features(JobsBackend::Db, "postgres"),
706            ", features = [\"jobs-db\", \"postgres\"]"
707        );
708        assert_eq!(
709            doido_jobs_features(JobsBackend::Redis, "sqlite"),
710            ", features = [\"jobs-redis\"]"
711        );
712        assert_eq!(doido_jobs_features(JobsBackend::Memory, "mysql"), "");
713    }
714
715    #[test]
716    fn path_doido_model_postgres_line_is_valid_toml() {
717        let line = format!(
718            "doido-model = {}",
719            dependency_spec(
720                true,
721                "/home/dev/doido",
722                "0.0.9",
723                "doido-model",
724                ", features = [\"postgres\"]",
725            )
726        );
727        assert!(line.contains("path = \"/home/dev/doido/doido-model\""));
728        assert!(line.contains("postgres"));
729        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
730    }
731
732    #[test]
733    fn path_jobs_redis_line_is_valid_toml() {
734        let line = format!(
735            "doido-jobs = {}",
736            dependency_spec(
737                true,
738                "/home/dev/doido",
739                "0.0.9",
740                "doido-jobs",
741                ", features = [\"jobs-redis\"]",
742            )
743        );
744        assert!(line.contains("path = \"/home/dev/doido/doido-jobs\""));
745        assert!(line.contains("jobs-redis"));
746        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
747    }
748
749    #[test]
750    fn postgres_url_has_default_user_password_and_port() {
751        assert_eq!(
752            default_database_url("postgres", "blog", "development"),
753            "postgres://postgres:postgres@localhost:5432/blog_development"
754        );
755    }
756
757    #[test]
758    fn production_password_is_a_placeholder() {
759        assert_eq!(
760            default_database_url("postgres", "blog", "production"),
761            "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
762        );
763    }
764
765    #[test]
766    fn sqlite_stays_a_bare_file_path() {
767        assert_eq!(
768            default_database_url("sqlite", "blog", "development"),
769            "sqlite://db/development.db"
770        );
771    }
772
773    #[test]
774    fn parse_cache_accepts_memcached_alias() {
775        assert_eq!(parse_cache("memcached").unwrap(), CacheBackend::Memcache);
776    }
777
778    #[test]
779    fn parse_jobs_accepts_database_alias() {
780        assert_eq!(parse_jobs("database").unwrap(), JobsBackend::Db);
781    }
782
783    #[test]
784    fn compose_includes_redis_for_cache_redis() {
785        let svc = compose_services(
786            "sqlite",
787            "app",
788            false,
789            CacheBackend::Redis,
790            JobsBackend::Memory,
791        );
792        assert!(svc.contains("redis:8-alpine"));
793        assert!(!svc.contains("postgres:"));
794    }
795
796    #[test]
797    fn compose_includes_memcache_for_cache_memcache() {
798        let svc = compose_services(
799            "sqlite",
800            "app",
801            false,
802            CacheBackend::Memcache,
803            JobsBackend::Memory,
804        );
805        assert!(svc.contains("memcached:1.6-alpine"));
806        assert!(!svc.contains("redis:"));
807    }
808
809    #[test]
810    fn compose_deduplicates_redis_when_cable_and_jobs_redis() {
811        let svc = compose_services(
812            "sqlite",
813            "app",
814            true,
815            CacheBackend::Memory,
816            JobsBackend::Redis,
817        );
818        assert_eq!(svc.matches("image: redis:").count(), 1);
819    }
820
821    #[test]
822    fn compose_database_url_uses_docker_hostnames() {
823        assert_eq!(
824            compose_database_url_for_docker("postgres", "blog"),
825            "postgres://postgres:postgres@postgres:5432/blog_development"
826        );
827    }
828
829    #[test]
830    fn compose_always_includes_mailpit() {
831        let svc = compose_services(
832            "sqlite",
833            "app",
834            false,
835            CacheBackend::Memory,
836            JobsBackend::Memory,
837        );
838        assert!(svc.contains("mailpit:"));
839        assert!(svc.contains("axllent/mailpit"));
840    }
841
842    #[test]
843    fn compose_env_extras_wires_mailer_to_mailpit() {
844        let env = compose_env_extras(CacheBackend::Memory, JobsBackend::Memory);
845        assert!(env.contains("MAILER__TYPE: smtp"));
846        assert!(env.contains("MAILER__SMTP__ADDRESS: mailpit:1025"));
847    }
848}