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