1use 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
26static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
28
29const CABLE_TEMPLATE_PREFIX: &str = "app/channels/";
32
33const CABLE_MODULE_INCLUDE: &str = "\n#[path = \"../app/channels/mod.rs\"]\nmod channels;\n";
35
36const 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
81fn 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
92fn 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}