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