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::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_migration_dep: String,
69    doido_jobs_dep: String,
70    doido_model_dep: String,
71    cache_section: String,
72    jobs_section: String,
73    compose_services: String,
74    compose_depends_on: String,
75    compose_database_url: String,
76    compose_env_extras: String,
77    compose_web_volumes: String,
78}
79
80/// Renders a complete Cargo inline-table dependency for a first-party `doido-*` crate.
81fn doido_dependency(mode: &DependencyMode, subdir: &str, features: &str) -> String {
82    dependency_spec(
83        mode.use_path,
84        &mode.workspace_path,
85        mode.version,
86        subdir,
87        features,
88    )
89}
90
91/// `features` is an optional suffix such as `, features = ["cache-redis"]` (empty when none).
92fn dependency_spec(
93    use_path: bool,
94    workspace_path: &str,
95    version: &str,
96    subdir: &str,
97    features: &str,
98) -> String {
99    let inner = if use_path {
100        format!("path = \"{workspace_path}/{subdir}\"")
101    } else {
102        format!("version = \"{version}\"")
103    };
104    format!("{{ {inner}{features} }}")
105}
106
107fn flag_value<'a>(args: &'a [&str], prefix: &str, default: &'a str) -> &'a str {
108    args.iter()
109        .find(|a| a.starts_with(prefix))
110        .and_then(|a| a.split_once('=').map(|(_, v)| v))
111        .unwrap_or(default)
112}
113
114fn doido_features(cache: CacheBackend, database: &str) -> String {
115    let mut feats = vec![format!("\"{database}\"")];
116    match cache {
117        CacheBackend::Redis => feats.push("\"cache-redis\"".to_string()),
118        CacheBackend::Memcache => feats.push("\"cache-memcache\"".to_string()),
119        CacheBackend::Memory => {}
120    }
121    format!(
122        ", default-features = false, features = [{}]",
123        feats.join(", ")
124    )
125}
126
127fn doido_jobs_features(jobs: JobsBackend, database: &str) -> String {
128    match jobs {
129        JobsBackend::Db => format!(", features = [\"jobs-db\", \"{database}\"]"),
130        JobsBackend::Redis => ", features = [\"jobs-redis\"]".to_string(),
131        JobsBackend::Memory => String::new(),
132    }
133}
134
135fn doido_migration_features(database: &str) -> String {
136    format!(", default-features = false, features = [\"{database}\", \"cli\"]")
137}
138
139fn doido_model_features(database: &str) -> String {
140    match database {
141        "postgres" => ", features = [\"postgres\"]".to_string(),
142        "mysql" => ", features = [\"mysql\"]".to_string(),
143        _ => ", features = [\"sqlite\"]".to_string(),
144    }
145}
146
147fn render_cache_section(cache: CacheBackend, name: &str) -> String {
148    match cache {
149        CacheBackend::Memory => "cache:\n  type: memory\n".to_string(),
150        CacheBackend::Redis => format!(
151            "cache:\n  type: redis\n  endpoint: redis://127.0.0.1:6379\n  namespace: {name}\n"
152        ),
153        CacheBackend::Memcache => format!(
154            "cache:\n  type: memcache\n  endpoint: memcache://127.0.0.1:11211\n  namespace: {name}\n"
155        ),
156    }
157}
158
159fn render_jobs_section(jobs: JobsBackend, name: &str) -> String {
160    match jobs {
161        JobsBackend::Memory => "jobs:\n  type: memory\n".to_string(),
162        JobsBackend::Db => {
163            "jobs:\n  type: db\n  queues: [default]\n  concurrency: 5\n".to_string()
164        }
165        JobsBackend::Redis => format!(
166            "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"
167        ),
168    }
169}
170
171fn needs_redis(cable: bool, cache: CacheBackend, jobs: JobsBackend) -> bool {
172    cable || cache == CacheBackend::Redis || jobs == JobsBackend::Redis
173}
174
175fn compose_database_url_for_docker(database: &str, name: &str) -> String {
176    match database {
177        "postgres" => format!("postgres://postgres:postgres@postgres:5432/{name}_development"),
178        "mysql" => format!("mysql://root:password@mysql:3306/{name}_development"),
179        _ => "sqlite://db/development.db".to_string(),
180    }
181}
182
183fn compose_postgres_service(name: &str) -> String {
184    format!(
185        r#"  postgres:
186    image: postgres:18-alpine
187    environment:
188      POSTGRES_USER: postgres
189      POSTGRES_PASSWORD: postgres
190      POSTGRES_DB: {name}_development
191    ports:
192      - "5432:5432"
193    healthcheck:
194      test: ["CMD-SHELL", "pg_isready -U postgres"]
195      interval: 2s
196      timeout: 3s
197      retries: 15"#
198    )
199}
200
201fn compose_mysql_service(name: &str) -> String {
202    format!(
203        r#"  mysql:
204    image: mysql:lts
205    environment:
206      MYSQL_ROOT_PASSWORD: password
207      MYSQL_DATABASE: {name}_development
208    ports:
209      - "3306:3306"
210    healthcheck:
211      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
212      interval: 2s
213      timeout: 3s
214      retries: 15"#
215    )
216}
217
218fn compose_redis_service() -> &'static str {
219    r#"  redis:
220    image: redis:8-alpine
221    ports:
222      - "6379:6379"
223    healthcheck:
224      test: ["CMD", "redis-cli", "ping"]
225      interval: 2s
226      timeout: 3s
227      retries: 15"#
228}
229
230fn compose_memcache_service() -> &'static str {
231    r#"  memcache:
232    image: memcached:1.6-alpine
233    ports:
234      - "11211:11211""#
235}
236
237fn compose_services(
238    database: &str,
239    name: &str,
240    cable: bool,
241    cache: CacheBackend,
242    jobs: JobsBackend,
243) -> String {
244    let mut parts = Vec::new();
245    match database {
246        "postgres" => parts.push(compose_postgres_service(name)),
247        "mysql" => parts.push(compose_mysql_service(name)),
248        _ => {}
249    }
250    if needs_redis(cable, cache, jobs) {
251        parts.push(compose_redis_service().to_string());
252    }
253    if cache == CacheBackend::Memcache {
254        parts.push(compose_memcache_service().to_string());
255    }
256    parts.join("\n\n")
257}
258
259fn compose_depends_on(
260    database: &str,
261    cable: bool,
262    cache: CacheBackend,
263    jobs: JobsBackend,
264) -> String {
265    let mut deps = Vec::new();
266    match database {
267        "postgres" => deps.push("      postgres:\n        condition: service_healthy"),
268        "mysql" => deps.push("      mysql:\n        condition: service_healthy"),
269        _ => {}
270    }
271    if needs_redis(cable, cache, jobs) {
272        deps.push("      redis:\n        condition: service_healthy");
273    }
274    if deps.is_empty() {
275        String::new()
276    } else {
277        format!("    depends_on:\n{}", deps.join("\n"))
278    }
279}
280
281fn compose_env_extras(cache: CacheBackend, jobs: JobsBackend) -> String {
282    let mut lines = Vec::new();
283    match cache {
284        CacheBackend::Redis => lines.push("      CACHE__ENDPOINT: redis://redis:6379"),
285        CacheBackend::Memcache => lines.push("      CACHE__ENDPOINT: memcache://memcache:11211"),
286        CacheBackend::Memory => {}
287    }
288    if jobs == JobsBackend::Redis {
289        lines.push("      JOBS__REDIS__URL: redis://redis:6379");
290    }
291    if lines.is_empty() {
292        String::new()
293    } else {
294        lines.join("\n")
295    }
296}
297
298fn compose_web_volumes(database: &str) -> String {
299    if database == "sqlite" {
300        "      - ./db:/app/db\n".to_string()
301    } else {
302        String::new()
303    }
304}
305
306fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
307    let (cable_deps, cable_module, cable_readme) = if ctx.cable {
308        (
309            format!(
310                "doido-cable = {}\nasync-trait = \"0.1\"\n",
311                doido_dependency(&ctx.dep_mode, "doido-cable", "")
312            ),
313            CABLE_MODULE_INCLUDE.to_string(),
314            CABLE_README_SECTION.replace("{doido_name}", ctx.name),
315        )
316    } else {
317        (String::new(), String::new(), String::new())
318    };
319
320    template
321        .replace("{doido_name}", ctx.name)
322        .replace("{doido_db_url_test}", &ctx.db_url_test)
323        .replace("{doido_db_url_production}", &ctx.db_url_production)
324        .replace("{doido_db_url}", &ctx.db_url)
325        .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
326        .replace("{doido_dep}", &ctx.doido_dep)
327        .replace("{doido_migration_dep}", &ctx.doido_migration_dep)
328        .replace(
329            "{doido_core_dep}",
330            &doido_dependency(&ctx.dep_mode, "doido-core", ""),
331        )
332        .replace(
333            "{doido_controller_dep}",
334            &doido_dependency(
335                &ctx.dep_mode,
336                "doido-controller",
337                &doido_model_features(ctx.sqlx_feature),
338            ),
339        )
340        .replace("{doido_jobs_dep}", &ctx.doido_jobs_dep)
341        .replace(
342            "{doido_mailer_dep}",
343            &doido_dependency(&ctx.dep_mode, "doido-mailer", ""),
344        )
345        .replace("{doido_model_dep}", &ctx.doido_model_dep)
346        .replace("{doido_cable_deps}", &cable_deps)
347        .replace("{doido_channels_module}", &cable_module)
348        .replace("{doido_cable_readme}", &cable_readme)
349        .replace("{doido_cache_section}", &ctx.cache_section)
350        .replace("{doido_jobs_section}", &ctx.jobs_section)
351        .replace("{doido_compose_services}", &ctx.compose_services)
352        .replace("{doido_compose_depends_on}", &ctx.compose_depends_on)
353        .replace("{doido_compose_database_url}", &ctx.compose_database_url)
354        .replace("{doido_compose_env_extras}", &ctx.compose_env_extras)
355        .replace("{doido_compose_web_volumes}", &ctx.compose_web_volumes)
356        .replace("{doido_path}", &ctx.dep_mode.workspace_path)
357}
358
359fn collect_from_dir(
360    dir: &Dir<'_>,
361    ctx: &TemplateContext<'_>,
362    app_name: &str,
363    out: &mut Vec<GeneratedFile>,
364) -> Result<()> {
365    for entry in dir.entries() {
366        match entry {
367            DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
368            DirEntry::File(f) => {
369                let relative = f.path();
370                if !ctx.cable && relative.starts_with(CABLE_TEMPLATE_PREFIX) {
371                    continue;
372                }
373                let raw = f.contents_utf8().ok_or_else(|| {
374                    anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
375                })?;
376                let rendered = substitute_template(raw, ctx);
377                let relative = relative.to_string_lossy().replace('\\', "/");
378                let relative = relative.strip_suffix(".template").unwrap_or(&relative);
379                let disk_path = format!("{app_name}/{relative}");
380                out.push(GeneratedFile {
381                    path: disk_path,
382                    content: rendered,
383                });
384            }
385        }
386    }
387    Ok(())
388}
389
390struct DbDefaults {
391    scheme: &'static str,
392    user: &'static str,
393    password: &'static str,
394    port: u16,
395}
396
397fn db_defaults(backend: &str) -> Option<DbDefaults> {
398    match backend {
399        "postgres" => Some(DbDefaults {
400            scheme: "postgres",
401            user: "postgres",
402            password: "postgres",
403            port: 5432,
404        }),
405        "mysql" => Some(DbDefaults {
406            scheme: "mysql",
407            user: "root",
408            password: "password",
409            port: 3306,
410        }),
411        _ => None,
412    }
413}
414
415fn default_database_url(backend: &str, name: &str, env: &str) -> String {
416    match db_defaults(backend) {
417        Some(d) => {
418            let password = if env == "production" {
419                "CHANGE_ME"
420            } else {
421                d.password
422            };
423            format!(
424                "{}://{}:{}@localhost:{}/{}_{}",
425                d.scheme, d.user, password, d.port, name, env
426            )
427        }
428        None => format!("sqlite://db/{env}.db"),
429    }
430}
431
432pub struct ProjectGenerator;
433
434impl Generator for ProjectGenerator {
435    fn name(&self) -> &str {
436        "new"
437    }
438
439    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
440        let name = args
441            .first()
442            .copied()
443            .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
444
445        let database = parse_database(flag_value(args, "--database=", "sqlite"))?;
446        let cache = parse_cache(flag_value(args, "--cache=", "memory"))?;
447        let jobs = parse_jobs(flag_value(args, "--jobs=", "memory"))?;
448        let cable = args.contains(&"--cable");
449
450        let database = database.as_str();
451        let db_url = default_database_url(database, name, "development");
452        let db_url_test = default_database_url(database, name, "test");
453        let db_url_production = default_database_url(database, name, "production");
454
455        let sqlx_feature = match database {
456            "postgres" => "postgres",
457            "mysql" => "mysql",
458            _ => "sqlite",
459        };
460
461        let dep_mode = DependencyMode::resolve();
462
463        let ctx = TemplateContext {
464            name,
465            db_url,
466            db_url_test,
467            db_url_production,
468            sqlx_feature,
469            cable,
470            doido_dep: doido_dependency(&dep_mode, "doido", &doido_features(cache, database)),
471            doido_migration_dep: doido_dependency(
472                &dep_mode,
473                "doido",
474                &doido_migration_features(database),
475            ),
476            doido_jobs_dep: doido_dependency(
477                &dep_mode,
478                "doido-jobs",
479                &doido_jobs_features(jobs, database),
480            ),
481            doido_model_dep: doido_dependency(
482                &dep_mode,
483                "doido-model",
484                &doido_model_features(database),
485            ),
486            dep_mode,
487            cache_section: render_cache_section(cache, name),
488            jobs_section: render_jobs_section(jobs, name),
489            compose_services: compose_services(database, name, cable, cache, jobs),
490            compose_depends_on: compose_depends_on(database, cable, cache, jobs),
491            compose_database_url: compose_database_url_for_docker(database, name),
492            compose_env_extras: compose_env_extras(cache, jobs),
493            compose_web_volumes: compose_web_volumes(database),
494        };
495
496        let mut files = Vec::new();
497        collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
498        files.sort_by(|a, b| a.path.cmp(&b.path));
499        Ok(files)
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::new_options::{parse_cache, parse_jobs, JobsBackend};
507
508    #[test]
509    fn local_builds_emit_path_dependencies() {
510        assert_eq!(
511            dependency_spec(true, "/home/dev/doido", "0.0.6", "doido", ""),
512            "{ path = \"/home/dev/doido/doido\" }"
513        );
514    }
515
516    #[test]
517    fn published_builds_emit_version_dependencies() {
518        assert_eq!(
519            dependency_spec(false, "/irrelevant", "0.0.6", "doido", ""),
520            "{ version = \"0.0.6\" }"
521        );
522    }
523
524    #[test]
525    fn path_dependency_with_features_stays_inside_inline_table() {
526        assert_eq!(
527            dependency_spec(
528                true,
529                "/home/dev/doido",
530                "0.0.6",
531                "doido",
532                ", features = [\"cache-redis\"]",
533            ),
534            "{ path = \"/home/dev/doido/doido\", features = [\"cache-redis\"] }"
535        );
536    }
537
538    #[test]
539    fn published_dependency_with_features_stays_inside_inline_table() {
540        assert_eq!(
541            dependency_spec(
542                false,
543                "/irrelevant",
544                "0.0.6",
545                "doido-jobs",
546                ", features = [\"jobs-redis\"]",
547            ),
548            "{ version = \"0.0.6\", features = [\"jobs-redis\"] }"
549        );
550    }
551
552    fn assert_cargo_toml_parses(cargo_toml: &str) {
553        cargo_toml
554            .parse::<toml::Table>()
555            .expect("valid Cargo.toml TOML");
556    }
557
558    fn minimal_cargo_with_doido_line(doido_line: &str) -> String {
559        format!(
560            r#"[package]
561name = "app"
562version = "0.1.0"
563edition = "2021"
564
565[dependencies]
566{doido_line}
567"#
568        )
569    }
570
571    #[test]
572    fn doido_features_include_database_and_cache() {
573        assert_eq!(
574            doido_features(CacheBackend::Memory, "postgres"),
575            ", default-features = false, features = [\"postgres\"]"
576        );
577        assert_eq!(
578            doido_features(CacheBackend::Redis, "sqlite"),
579            ", default-features = false, features = [\"sqlite\", \"cache-redis\"]"
580        );
581    }
582
583    #[test]
584    fn published_cache_redis_line_is_valid_toml() {
585        let line = format!(
586            "doido = {}",
587            dependency_spec(
588                false,
589                "/irrelevant",
590                "0.0.9",
591                "doido",
592                &doido_features(CacheBackend::Redis, "sqlite"),
593            )
594        );
595        assert!(!line.contains("path ="));
596        assert!(line.contains("version = \"0.0.9\""));
597        assert!(line.contains("cache-redis"));
598        assert!(line.contains("sqlite"));
599        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
600    }
601
602    #[test]
603    fn doido_migration_features_include_database_and_cli() {
604        assert_eq!(
605            doido_migration_features("sqlite"),
606            ", default-features = false, features = [\"sqlite\", \"cli\"]"
607        );
608        assert_eq!(
609            doido_migration_features("postgres"),
610            ", default-features = false, features = [\"postgres\", \"cli\"]"
611        );
612    }
613
614    #[test]
615    fn doido_model_features_match_database_backend() {
616        assert_eq!(doido_model_features("sqlite"), ", features = [\"sqlite\"]");
617        assert_eq!(
618            doido_model_features("postgres"),
619            ", features = [\"postgres\"]"
620        );
621        assert_eq!(doido_model_features("mysql"), ", features = [\"mysql\"]");
622    }
623
624    #[test]
625    fn doido_jobs_db_features_include_database_driver() {
626        assert_eq!(
627            doido_jobs_features(JobsBackend::Db, "postgres"),
628            ", features = [\"jobs-db\", \"postgres\"]"
629        );
630        assert_eq!(
631            doido_jobs_features(JobsBackend::Redis, "sqlite"),
632            ", features = [\"jobs-redis\"]"
633        );
634        assert_eq!(doido_jobs_features(JobsBackend::Memory, "mysql"), "");
635    }
636
637    #[test]
638    fn path_doido_model_postgres_line_is_valid_toml() {
639        let line = format!(
640            "doido-model = {}",
641            dependency_spec(
642                true,
643                "/home/dev/doido",
644                "0.0.9",
645                "doido-model",
646                ", features = [\"postgres\"]",
647            )
648        );
649        assert!(line.contains("path = \"/home/dev/doido/doido-model\""));
650        assert!(line.contains("postgres"));
651        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
652    }
653
654    #[test]
655    fn path_jobs_redis_line_is_valid_toml() {
656        let line = format!(
657            "doido-jobs = {}",
658            dependency_spec(
659                true,
660                "/home/dev/doido",
661                "0.0.9",
662                "doido-jobs",
663                ", features = [\"jobs-redis\"]",
664            )
665        );
666        assert!(line.contains("path = \"/home/dev/doido/doido-jobs\""));
667        assert!(line.contains("jobs-redis"));
668        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
669    }
670
671    #[test]
672    fn postgres_url_has_default_user_password_and_port() {
673        assert_eq!(
674            default_database_url("postgres", "blog", "development"),
675            "postgres://postgres:postgres@localhost:5432/blog_development"
676        );
677    }
678
679    #[test]
680    fn production_password_is_a_placeholder() {
681        assert_eq!(
682            default_database_url("postgres", "blog", "production"),
683            "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
684        );
685    }
686
687    #[test]
688    fn sqlite_stays_a_bare_file_path() {
689        assert_eq!(
690            default_database_url("sqlite", "blog", "development"),
691            "sqlite://db/development.db"
692        );
693    }
694
695    #[test]
696    fn parse_cache_accepts_memcached_alias() {
697        assert_eq!(parse_cache("memcached").unwrap(), CacheBackend::Memcache);
698    }
699
700    #[test]
701    fn parse_jobs_accepts_database_alias() {
702        assert_eq!(parse_jobs("database").unwrap(), JobsBackend::Db);
703    }
704
705    #[test]
706    fn compose_includes_redis_for_cache_redis() {
707        let svc = compose_services(
708            "sqlite",
709            "app",
710            false,
711            CacheBackend::Redis,
712            JobsBackend::Memory,
713        );
714        assert!(svc.contains("redis:8-alpine"));
715        assert!(!svc.contains("postgres:"));
716    }
717
718    #[test]
719    fn compose_includes_memcache_for_cache_memcache() {
720        let svc = compose_services(
721            "sqlite",
722            "app",
723            false,
724            CacheBackend::Memcache,
725            JobsBackend::Memory,
726        );
727        assert!(svc.contains("memcached:1.6-alpine"));
728        assert!(!svc.contains("redis:"));
729    }
730
731    #[test]
732    fn compose_deduplicates_redis_when_cable_and_jobs_redis() {
733        let svc = compose_services(
734            "sqlite",
735            "app",
736            true,
737            CacheBackend::Memory,
738            JobsBackend::Redis,
739        );
740        assert_eq!(svc.matches("image: redis:").count(), 1);
741    }
742
743    #[test]
744    fn compose_database_url_uses_docker_hostnames() {
745        assert_eq!(
746            compose_database_url_for_docker("postgres", "blog"),
747            "postgres://postgres:postgres@postgres:5432/blog_development"
748        );
749    }
750}