1use crate::generator::{GeneratedFile, Generator};
21use crate::new_options::{parse_cache, parse_database, parse_jobs, CacheBackend, JobsBackend};
22use doido_core::{anyhow, Result};
23use include_dir::{include_dir, Dir, DirEntry};
24
25static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
27
28const CABLE_TEMPLATE_PREFIX: &str = "app/channels/";
31
32const CABLE_MODULE_INCLUDE: &str = "\n#[path = \"../app/channels/mod.rs\"]\nmod channels;\n";
34
35const CABLE_README_SECTION: &str = r#"
38## Real-time with doido-cable
39
40This app was generated with `--cable`, so it includes:
41
42- the `doido-cable` (and `async-trait`) dependencies in `Cargo.toml`;
43- an example channel at `app/channels/chat_channel.rs`, registered in
44 `app/channels/mod.rs` and wired into the crate via `mod channels;` in
45 `src/main.rs`.
46
47A channel implements the `Channel` trait — `subscribed`, `unsubscribed`, and
48`received` — and broadcasts to other clients through a shared `Cable` handle over
49a pub/sub backend (`MemoryPubSub` by default; Redis/DB are swappable). See the
50`#[tokio::test]` in `app/channels/chat_channel.rs` for a runnable
51subscribe → broadcast → receive round-trip:
52
53```sh
54cargo test --bin {doido_name} chat
55```
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 doido_dep: String,
67 doido_jobs_dep: String,
68 cache_section: String,
69 jobs_section: String,
70 compose_services: String,
71 compose_depends_on: String,
72 compose_database_url: String,
73 compose_env_extras: String,
74 compose_web_volumes: String,
75}
76
77fn doido_dependency(subdir: &str, features: &str) -> String {
79 dependency_spec(
80 crate::TEMPLATE_USE_PATH_DEPS,
81 crate::TEMPLATE_WORKSPACE_PATH,
82 crate::DOIDO_VERSION,
83 subdir,
84 features,
85 )
86}
87
88fn dependency_spec(
90 use_path: bool,
91 workspace_path: &str,
92 version: &str,
93 subdir: &str,
94 features: &str,
95) -> String {
96 let inner = if use_path {
97 format!("path = \"{workspace_path}/{subdir}\"")
98 } else {
99 format!("version = \"{version}\"")
100 };
101 format!("{{ {inner}{features} }}")
102}
103
104fn flag_value<'a>(args: &'a [&str], prefix: &str, default: &'a str) -> &'a str {
105 args.iter()
106 .find(|a| a.starts_with(prefix))
107 .and_then(|a| a.split_once('=').map(|(_, v)| v))
108 .unwrap_or(default)
109}
110
111fn doido_features(cache: CacheBackend) -> String {
112 match cache {
113 CacheBackend::Redis => ", features = [\"cache-redis\"]".to_string(),
114 CacheBackend::Memcache => ", features = [\"cache-memcache\"]".to_string(),
115 CacheBackend::Memory => String::new(),
116 }
117}
118
119fn doido_jobs_features(jobs: JobsBackend) -> String {
120 match jobs {
121 JobsBackend::Db => ", features = [\"jobs-db\"]".to_string(),
122 JobsBackend::Redis => ", features = [\"jobs-redis\"]".to_string(),
123 JobsBackend::Memory => String::new(),
124 }
125}
126
127fn render_cache_section(cache: CacheBackend, name: &str) -> String {
128 match cache {
129 CacheBackend::Memory => "cache:\n type: memory\n".to_string(),
130 CacheBackend::Redis => format!(
131 "cache:\n type: redis\n endpoint: redis://127.0.0.1:6379\n namespace: {name}\n"
132 ),
133 CacheBackend::Memcache => format!(
134 "cache:\n type: memcache\n endpoint: memcache://127.0.0.1:11211\n namespace: {name}\n"
135 ),
136 }
137}
138
139fn render_jobs_section(jobs: JobsBackend, name: &str) -> String {
140 match jobs {
141 JobsBackend::Memory => "jobs:\n type: memory\n".to_string(),
142 JobsBackend::Db => {
143 "jobs:\n type: db\n queues: [default]\n concurrency: 5\n".to_string()
144 }
145 JobsBackend::Redis => format!(
146 "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"
147 ),
148 }
149}
150
151fn needs_redis(cable: bool, cache: CacheBackend, jobs: JobsBackend) -> bool {
152 cable || cache == CacheBackend::Redis || jobs == JobsBackend::Redis
153}
154
155fn compose_database_url_for_docker(database: &str, name: &str) -> String {
156 match database {
157 "postgres" => format!("postgres://postgres:postgres@postgres:5432/{name}_development"),
158 "mysql" => format!("mysql://root:password@mysql:3306/{name}_development"),
159 _ => "sqlite://db/development.db".to_string(),
160 }
161}
162
163fn compose_postgres_service(name: &str) -> String {
164 format!(
165 r#" postgres:
166 image: postgres:18-alpine
167 environment:
168 POSTGRES_USER: postgres
169 POSTGRES_PASSWORD: postgres
170 POSTGRES_DB: {name}_development
171 ports:
172 - "5432:5432"
173 healthcheck:
174 test: ["CMD-SHELL", "pg_isready -U postgres"]
175 interval: 2s
176 timeout: 3s
177 retries: 15"#
178 )
179}
180
181fn compose_mysql_service(name: &str) -> String {
182 format!(
183 r#" mysql:
184 image: mysql:lts
185 environment:
186 MYSQL_ROOT_PASSWORD: password
187 MYSQL_DATABASE: {name}_development
188 ports:
189 - "3306:3306"
190 healthcheck:
191 test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
192 interval: 2s
193 timeout: 3s
194 retries: 15"#
195 )
196}
197
198fn compose_redis_service() -> &'static str {
199 r#" redis:
200 image: redis:8-alpine
201 ports:
202 - "6379:6379"
203 healthcheck:
204 test: ["CMD", "redis-cli", "ping"]
205 interval: 2s
206 timeout: 3s
207 retries: 15"#
208}
209
210fn compose_memcache_service() -> &'static str {
211 r#" memcache:
212 image: memcached:1.6-alpine
213 ports:
214 - "11211:11211""#
215}
216
217fn compose_services(database: &str, name: &str, cable: bool, cache: CacheBackend, jobs: JobsBackend) -> String {
218 let mut parts = Vec::new();
219 match database {
220 "postgres" => parts.push(compose_postgres_service(name)),
221 "mysql" => parts.push(compose_mysql_service(name)),
222 _ => {}
223 }
224 if needs_redis(cable, cache, jobs) {
225 parts.push(compose_redis_service().to_string());
226 }
227 if cache == CacheBackend::Memcache {
228 parts.push(compose_memcache_service().to_string());
229 }
230 parts.join("\n\n")
231}
232
233fn compose_depends_on(database: &str, cable: bool, cache: CacheBackend, jobs: JobsBackend) -> String {
234 let mut deps = Vec::new();
235 match database {
236 "postgres" => deps.push(" postgres:\n condition: service_healthy"),
237 "mysql" => deps.push(" mysql:\n condition: service_healthy"),
238 _ => {}
239 }
240 if needs_redis(cable, cache, jobs) {
241 deps.push(" redis:\n condition: service_healthy");
242 }
243 if deps.is_empty() {
244 String::new()
245 } else {
246 format!(" depends_on:\n{}", deps.join("\n"))
247 }
248}
249
250fn compose_env_extras(cache: CacheBackend, jobs: JobsBackend) -> String {
251 let mut lines = Vec::new();
252 match cache {
253 CacheBackend::Redis => lines.push(" CACHE__ENDPOINT: redis://redis:6379"),
254 CacheBackend::Memcache => lines.push(" CACHE__ENDPOINT: memcache://memcache:11211"),
255 CacheBackend::Memory => {}
256 }
257 if jobs == JobsBackend::Redis {
258 lines.push(" JOBS__REDIS__URL: redis://redis:6379");
259 }
260 if lines.is_empty() {
261 String::new()
262 } else {
263 lines.join("\n")
264 }
265}
266
267fn compose_web_volumes(database: &str) -> String {
268 if database == "sqlite" {
269 " - ./db:/app/db\n".to_string()
270 } else {
271 String::new()
272 }
273}
274
275fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
276 let (cable_deps, cable_module, cable_readme) = if ctx.cable {
277 (
278 format!(
279 "doido-cable = {}\nasync-trait = \"0.1\"\n",
280 doido_dependency("doido-cable", "")
281 ),
282 CABLE_MODULE_INCLUDE.to_string(),
283 CABLE_README_SECTION.replace("{doido_name}", ctx.name),
284 )
285 } else {
286 (String::new(), String::new(), String::new())
287 };
288
289 template
290 .replace("{doido_name}", ctx.name)
291 .replace("{doido_db_url_test}", &ctx.db_url_test)
292 .replace("{doido_db_url_production}", &ctx.db_url_production)
293 .replace("{doido_db_url}", &ctx.db_url)
294 .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
295 .replace("{doido_dep}", &ctx.doido_dep)
296 .replace("{doido_core_dep}", &doido_dependency("doido-core", ""))
297 .replace(
298 "{doido_controller_dep}",
299 &doido_dependency("doido-controller", ""),
300 )
301 .replace("{doido_jobs_dep}", &ctx.doido_jobs_dep)
302 .replace("{doido_mailer_dep}", &doido_dependency("doido-mailer", ""))
303 .replace("{doido_model_dep}", &doido_dependency("doido-model", ""))
304 .replace("{doido_cable_deps}", &cable_deps)
305 .replace("{doido_channels_module}", &cable_module)
306 .replace("{doido_cable_readme}", &cable_readme)
307 .replace("{doido_cache_section}", &ctx.cache_section)
308 .replace("{doido_jobs_section}", &ctx.jobs_section)
309 .replace("{doido_compose_services}", &ctx.compose_services)
310 .replace("{doido_compose_depends_on}", &ctx.compose_depends_on)
311 .replace("{doido_compose_database_url}", &ctx.compose_database_url)
312 .replace("{doido_compose_env_extras}", &ctx.compose_env_extras)
313 .replace("{doido_compose_web_volumes}", &ctx.compose_web_volumes)
314 .replace("{doido_path}", crate::TEMPLATE_WORKSPACE_PATH)
315}
316
317fn collect_from_dir(
318 dir: &Dir<'_>,
319 ctx: &TemplateContext<'_>,
320 app_name: &str,
321 out: &mut Vec<GeneratedFile>,
322) -> Result<()> {
323 for entry in dir.entries() {
324 match entry {
325 DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
326 DirEntry::File(f) => {
327 let relative = f.path();
328 if !ctx.cable && relative.starts_with(CABLE_TEMPLATE_PREFIX) {
329 continue;
330 }
331 let raw = f.contents_utf8().ok_or_else(|| {
332 anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
333 })?;
334 let rendered = substitute_template(raw, ctx);
335 let relative = relative.to_string_lossy().replace('\\', "/");
336 let relative = relative.strip_suffix(".template").unwrap_or(&relative);
337 let disk_path = format!("{app_name}/{relative}");
338 out.push(GeneratedFile {
339 path: disk_path,
340 content: rendered,
341 });
342 }
343 }
344 }
345 Ok(())
346}
347
348struct DbDefaults {
349 scheme: &'static str,
350 user: &'static str,
351 password: &'static str,
352 port: u16,
353}
354
355fn db_defaults(backend: &str) -> Option<DbDefaults> {
356 match backend {
357 "postgres" => Some(DbDefaults {
358 scheme: "postgres",
359 user: "postgres",
360 password: "postgres",
361 port: 5432,
362 }),
363 "mysql" => Some(DbDefaults {
364 scheme: "mysql",
365 user: "root",
366 password: "password",
367 port: 3306,
368 }),
369 _ => None,
370 }
371}
372
373fn default_database_url(backend: &str, name: &str, env: &str) -> String {
374 match db_defaults(backend) {
375 Some(d) => {
376 let password = if env == "production" {
377 "CHANGE_ME"
378 } else {
379 d.password
380 };
381 format!(
382 "{}://{}:{}@localhost:{}/{}_{}",
383 d.scheme, d.user, password, d.port, name, env
384 )
385 }
386 None => format!("sqlite://db/{env}.db"),
387 }
388}
389
390pub struct ProjectGenerator;
391
392impl Generator for ProjectGenerator {
393 fn name(&self) -> &str {
394 "new"
395 }
396
397 fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
398 let name = args
399 .first()
400 .copied()
401 .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
402
403 let database = parse_database(flag_value(args, "--database=", "sqlite"))?;
404 let cache = parse_cache(flag_value(args, "--cache=", "memory"))?;
405 let jobs = parse_jobs(flag_value(args, "--jobs=", "memory"))?;
406 let cable = args.contains(&"--cable");
407
408 let database = database.as_str();
409 let db_url = default_database_url(database, name, "development");
410 let db_url_test = default_database_url(database, name, "test");
411 let db_url_production = default_database_url(database, name, "production");
412
413 let sqlx_feature = match database {
414 "postgres" => "postgres",
415 "mysql" => "mysql",
416 _ => "sqlite",
417 };
418
419 let ctx = TemplateContext {
420 name,
421 db_url,
422 db_url_test,
423 db_url_production,
424 sqlx_feature,
425 cable,
426 doido_dep: doido_dependency("doido", &doido_features(cache)),
427 doido_jobs_dep: doido_dependency("doido-jobs", &doido_jobs_features(jobs)),
428 cache_section: render_cache_section(cache, name),
429 jobs_section: render_jobs_section(jobs, name),
430 compose_services: compose_services(database, name, cable, cache, jobs),
431 compose_depends_on: compose_depends_on(database, cable, cache, jobs),
432 compose_database_url: compose_database_url_for_docker(database, name),
433 compose_env_extras: compose_env_extras(cache, jobs),
434 compose_web_volumes: compose_web_volumes(database),
435 };
436
437 let mut files = Vec::new();
438 collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
439 files.sort_by(|a, b| a.path.cmp(&b.path));
440 Ok(files)
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::new_options::{parse_cache, parse_jobs};
448
449 #[test]
450 fn local_builds_emit_path_dependencies() {
451 assert_eq!(
452 dependency_spec(true, "/home/dev/doido", "0.0.6", "doido", ""),
453 "{ path = \"/home/dev/doido/doido\" }"
454 );
455 }
456
457 #[test]
458 fn published_builds_emit_version_dependencies() {
459 assert_eq!(
460 dependency_spec(false, "/irrelevant", "0.0.6", "doido", ""),
461 "{ version = \"0.0.6\" }"
462 );
463 }
464
465 #[test]
466 fn path_dependency_with_features_stays_inside_inline_table() {
467 assert_eq!(
468 dependency_spec(
469 true,
470 "/home/dev/doido",
471 "0.0.6",
472 "doido",
473 ", features = [\"cache-redis\"]",
474 ),
475 "{ path = \"/home/dev/doido/doido\", features = [\"cache-redis\"] }"
476 );
477 }
478
479 #[test]
480 fn published_dependency_with_features_stays_inside_inline_table() {
481 assert_eq!(
482 dependency_spec(
483 false,
484 "/irrelevant",
485 "0.0.6",
486 "doido-jobs",
487 ", features = [\"jobs-redis\"]",
488 ),
489 "{ version = \"0.0.6\", features = [\"jobs-redis\"] }"
490 );
491 }
492
493 fn assert_cargo_toml_parses(cargo_toml: &str) {
494 cargo_toml.parse::<toml::Table>().expect("valid Cargo.toml TOML");
495 }
496
497 fn minimal_cargo_with_doido_line(doido_line: &str) -> String {
498 format!(
499 r#"[package]
500name = "app"
501version = "0.1.0"
502edition = "2021"
503
504[dependencies]
505{doido_line}
506"#
507 )
508 }
509
510 #[test]
511 fn published_cache_redis_line_is_valid_toml() {
512 let line = format!(
513 "doido = {}",
514 dependency_spec(
515 false,
516 "/irrelevant",
517 "0.0.9",
518 "doido",
519 ", features = [\"cache-redis\"]",
520 )
521 );
522 assert!(!line.contains("path ="));
523 assert!(line.contains("version = \"0.0.9\""));
524 assert!(line.contains("cache-redis"));
525 assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
526 }
527
528 #[test]
529 fn path_jobs_redis_line_is_valid_toml() {
530 let line = format!(
531 "doido-jobs = {}",
532 dependency_spec(
533 true,
534 "/home/dev/doido",
535 "0.0.9",
536 "doido-jobs",
537 ", features = [\"jobs-redis\"]",
538 )
539 );
540 assert!(line.contains("path = \"/home/dev/doido/doido-jobs\""));
541 assert!(line.contains("jobs-redis"));
542 assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
543 }
544
545 #[test]
546 fn postgres_url_has_default_user_password_and_port() {
547 assert_eq!(
548 default_database_url("postgres", "blog", "development"),
549 "postgres://postgres:postgres@localhost:5432/blog_development"
550 );
551 }
552
553 #[test]
554 fn production_password_is_a_placeholder() {
555 assert_eq!(
556 default_database_url("postgres", "blog", "production"),
557 "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
558 );
559 }
560
561 #[test]
562 fn sqlite_stays_a_bare_file_path() {
563 assert_eq!(
564 default_database_url("sqlite", "blog", "development"),
565 "sqlite://db/development.db"
566 );
567 }
568
569 #[test]
570 fn parse_cache_accepts_memcached_alias() {
571 assert_eq!(parse_cache("memcached").unwrap(), CacheBackend::Memcache);
572 }
573
574 #[test]
575 fn parse_jobs_accepts_database_alias() {
576 assert_eq!(parse_jobs("database").unwrap(), JobsBackend::Db);
577 }
578
579 #[test]
580 fn compose_includes_redis_for_cache_redis() {
581 let svc = compose_services("sqlite", "app", false, CacheBackend::Redis, JobsBackend::Memory);
582 assert!(svc.contains("redis:8-alpine"));
583 assert!(!svc.contains("postgres:"));
584 }
585
586 #[test]
587 fn compose_includes_memcache_for_cache_memcache() {
588 let svc = compose_services("sqlite", "app", false, CacheBackend::Memcache, JobsBackend::Memory);
589 assert!(svc.contains("memcached:1.6-alpine"));
590 assert!(!svc.contains("redis:"));
591 }
592
593 #[test]
594 fn compose_deduplicates_redis_when_cable_and_jobs_redis() {
595 let svc = compose_services("sqlite", "app", true, CacheBackend::Memory, JobsBackend::Redis);
596 assert_eq!(svc.matches("image: redis:").count(), 1);
597 }
598
599 #[test]
600 fn compose_database_url_uses_docker_hostnames() {
601 assert_eq!(
602 compose_database_url_for_docker("postgres", "blog"),
603 "postgres://postgres:postgres@postgres:5432/blog_development"
604 );
605 }
606}