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