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