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