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