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