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