Skip to main content

doido_generators/generators/
new.rs

1//! New application skeleton rendered from embedded files under `templates/new/`.
2//! Placeholders: `{doido_name}`, `{doido_db_url}`, `{doido_sqlx_feature}`,
3//! `{doido_path}` (absolute workspace root when the running binary lives inside a
4//! local checkout), and the per-crate dependency specs `{doido_dep}` /
5//! `{doido_controller_dep}` / `{doido_model_dep}` / `{doido_migration_dep}` which render as a local `path`
6//! dep when the binary runs from a development checkout or a crates.io `version`
7//! dep matching this binary's release otherwise (see [`DependencyMode`]).
8//!
9//! The optional doido-cable example lives inside this same template. Its channel
10//! files sit under `templates/new/app/channels/` (skipped unless `--cable` is
11//! passed), and the `{doido_cable_deps}` / `{doido_channels_module}` /
12//! `{doido_cable_readme}` placeholders in `Cargo.toml`, `src/main.rs`, and
13//! `README.md` render to their wiring when `--cable` is set, or to nothing
14//! otherwise (see [`substitute_template`]).
15//!
16//! Template files carrying a trailing `.template` suffix (e.g. `Cargo.toml.template`)
17//! have the suffix stripped on output; the suffix keeps `cargo package` from treating
18//! `templates/new/` as a nested crate and excluding it from the published tarball.
19
20use crate::dev_workspace::DependencyMode;
21use crate::generator::{GeneratedFile, Generator};
22use crate::generators::bootstrap_migrations::{apply_bootstrap_migrations, storage_config_section};
23use crate::new_options::{parse_cache, parse_database, parse_jobs, CacheBackend, JobsBackend};
24use doido_core::{anyhow, Result};
25use include_dir::{include_dir, Dir, DirEntry};
26
27/// Embedded filesystem tree merged at compile time from `templates/new`.
28static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
29
30/// Template subtree holding the doido-cable example. Files here are skipped
31/// unless `--cable` is passed.
32const CABLE_TEMPLATE_PREFIX: &str = "app/channels/";
33
34/// `mod channels;` include spliced into `src/main.rs` when `--cable` is passed.
35const CABLE_MODULE_INCLUDE: &str = "\n#[path = \"../app/channels/mod.rs\"]\nmod channels;\n";
36
37/// README section explaining how the generated doido-cable example is wired.
38/// `{doido_name}` is substituted like any other template token.
39const CABLE_README_SECTION: &str = r#"
40## Real-time with doido-cable
41
42This app was generated with `--cable`, so it includes:
43
44- the `doido-cable` (and `async-trait`) dependencies in `Cargo.toml`;
45- an example channel at `app/channels/chat_channel.rs`, registered in
46  `app/channels/mod.rs` and wired into the crate via `mod channels;` in
47  `src/main.rs`.
48
49A channel implements the `Channel` trait — `subscribed`, `unsubscribed`, and
50`received` — and broadcasts to other clients through a shared `Cable` handle over
51a pub/sub backend (`MemoryPubSub` by default; Redis/DB are swappable). See the
52`#[tokio::test]` in `app/channels/chat_channel.rs` for a runnable
53subscribe → broadcast → receive round-trip:
54
55```sh
56cargo test --bin {doido_name} chat
57```
58"#;
59
60struct TemplateContext<'a> {
61    name: &'a str,
62    db_url: String,
63    db_url_test: String,
64    db_url_production: String,
65    sqlx_feature: &'a str,
66    cable: bool,
67    auth: bool,
68    dep_mode: DependencyMode,
69    doido_dep: String,
70    doido_migration_dep: String,
71    doido_jobs_dep: String,
72    doido_model_dep: String,
73    cache_section: String,
74    jobs_section: String,
75    storage_section: String,
76    compose_services: String,
77    compose_depends_on: String,
78    compose_database_url: String,
79    compose_env_extras: String,
80    compose_web_volumes: String,
81}
82
83/// Renders a complete Cargo inline-table dependency for a first-party `doido-*` crate.
84fn doido_dependency(mode: &DependencyMode, subdir: &str, features: &str) -> String {
85    dependency_spec(
86        mode.use_path,
87        &mode.workspace_path,
88        mode.version,
89        subdir,
90        features,
91    )
92}
93
94/// `features` is an optional suffix such as `, features = ["cache-redis"]` (empty when none).
95fn dependency_spec(
96    use_path: bool,
97    workspace_path: &str,
98    version: &str,
99    subdir: &str,
100    features: &str,
101) -> String {
102    let inner = if use_path {
103        format!("path = \"{workspace_path}/{subdir}\"")
104    } else {
105        format!("version = \"{version}\"")
106    };
107    format!("{{ {inner}{features} }}")
108}
109
110fn flag_value<'a>(args: &'a [&str], prefix: &str, default: &'a str) -> &'a str {
111    args.iter()
112        .find(|a| a.starts_with(prefix))
113        .and_then(|a| a.split_once('=').map(|(_, v)| v))
114        .unwrap_or(default)
115}
116
117fn doido_features(cache: CacheBackend, database: &str) -> String {
118    let mut feats = vec![format!("\"{database}\"")];
119    match cache {
120        CacheBackend::Redis => feats.push("\"cache-redis\"".to_string()),
121        CacheBackend::Memcache => feats.push("\"cache-memcache\"".to_string()),
122        CacheBackend::Memory => {}
123    }
124    format!(
125        ", default-features = false, features = [{}]",
126        feats.join(", ")
127    )
128}
129
130fn doido_jobs_features(jobs: JobsBackend, database: &str) -> String {
131    match jobs {
132        JobsBackend::Db => format!(", features = [\"jobs-db\", \"{database}\"]"),
133        JobsBackend::Redis => ", features = [\"jobs-redis\"]".to_string(),
134        JobsBackend::Memory => String::new(),
135    }
136}
137
138fn doido_migration_features(database: &str) -> String {
139    format!(", default-features = false, features = [\"{database}\", \"cli\"]")
140}
141
142fn doido_model_features(database: &str) -> String {
143    match database {
144        "postgres" => ", features = [\"postgres\"]".to_string(),
145        "mysql" => ", features = [\"mysql\"]".to_string(),
146        _ => ", features = [\"sqlite\"]".to_string(),
147    }
148}
149
150fn render_cache_section(cache: CacheBackend, name: &str) -> String {
151    match cache {
152        CacheBackend::Memory => "cache:\n  type: memory\n".to_string(),
153        CacheBackend::Redis => format!(
154            "cache:\n  type: redis\n  endpoint: redis://127.0.0.1:6379\n  namespace: {name}\n"
155        ),
156        CacheBackend::Memcache => format!(
157            "cache:\n  type: memcache\n  endpoint: memcache://127.0.0.1:11211\n  namespace: {name}\n"
158        ),
159    }
160}
161
162fn render_jobs_section(jobs: JobsBackend, name: &str) -> String {
163    match jobs {
164        JobsBackend::Memory => "jobs:\n  type: memory\n".to_string(),
165        JobsBackend::Db => {
166            "jobs:\n  type: db\n  queues: [default]\n  concurrency: 5\n".to_string()
167        }
168        JobsBackend::Redis => format!(
169            "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"
170        ),
171    }
172}
173
174/// Development SMTP — Mailpit on localhost when running `cargo doido server`.
175const MAILER_DEV_SECTION: &str = "mailer:\n  type: smtp\n  smtp:\n    address: localhost:1025\n";
176
177fn compose_mailpit_service() -> &'static str {
178    r#"  mailpit:
179    image: axllent/mailpit:latest
180    ports:
181      - "1025:1025"
182      - "8025:8025""#
183}
184
185fn needs_redis(cable: bool, cache: CacheBackend, jobs: JobsBackend) -> bool {
186    cable || cache == CacheBackend::Redis || jobs == JobsBackend::Redis
187}
188
189fn compose_database_url_for_docker(database: &str, name: &str) -> String {
190    match database {
191        "postgres" => format!("postgres://postgres:postgres@postgres:5432/{name}_development"),
192        "mysql" => format!("mysql://root:password@mysql:3306/{name}_development"),
193        _ => "sqlite://db/development.db".to_string(),
194    }
195}
196
197fn compose_postgres_service(name: &str) -> String {
198    format!(
199        r#"  postgres:
200    image: postgres:18-alpine
201    environment:
202      POSTGRES_USER: postgres
203      POSTGRES_PASSWORD: postgres
204      POSTGRES_DB: {name}_development
205    ports:
206      - "5432:5432"
207    healthcheck:
208      test: ["CMD-SHELL", "pg_isready -U postgres"]
209      interval: 2s
210      timeout: 3s
211      retries: 15"#
212    )
213}
214
215fn compose_mysql_service(name: &str) -> String {
216    format!(
217        r#"  mysql:
218    image: mysql:lts
219    environment:
220      MYSQL_ROOT_PASSWORD: password
221      MYSQL_DATABASE: {name}_development
222    ports:
223      - "3306:3306"
224    healthcheck:
225      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
226      interval: 2s
227      timeout: 3s
228      retries: 15"#
229    )
230}
231
232fn compose_redis_service() -> &'static str {
233    r#"  redis:
234    image: redis:8-alpine
235    ports:
236      - "6379:6379"
237    healthcheck:
238      test: ["CMD", "redis-cli", "ping"]
239      interval: 2s
240      timeout: 3s
241      retries: 15"#
242}
243
244fn compose_memcache_service() -> &'static str {
245    r#"  memcache:
246    image: memcached:1.6-alpine
247    ports:
248      - "11211:11211""#
249}
250
251fn compose_services(
252    database: &str,
253    name: &str,
254    cable: bool,
255    cache: CacheBackend,
256    jobs: JobsBackend,
257) -> String {
258    let mut parts = Vec::new();
259    match database {
260        "postgres" => parts.push(compose_postgres_service(name)),
261        "mysql" => parts.push(compose_mysql_service(name)),
262        _ => {}
263    }
264    if needs_redis(cable, cache, jobs) {
265        parts.push(compose_redis_service().to_string());
266    }
267    if cache == CacheBackend::Memcache {
268        parts.push(compose_memcache_service().to_string());
269    }
270    parts.push(compose_mailpit_service().to_string());
271    parts.join("\n\n")
272}
273
274fn compose_depends_on(
275    database: &str,
276    cable: bool,
277    cache: CacheBackend,
278    jobs: JobsBackend,
279) -> String {
280    let mut deps = vec!["      mailpit:\n        condition: service_started"];
281    match database {
282        "postgres" => deps.push("      postgres:\n        condition: service_healthy"),
283        "mysql" => deps.push("      mysql:\n        condition: service_healthy"),
284        _ => {}
285    }
286    if needs_redis(cable, cache, jobs) {
287        deps.push("      redis:\n        condition: service_healthy");
288    }
289    format!("    depends_on:\n{}", deps.join("\n"))
290}
291
292fn compose_env_extras(cache: CacheBackend, jobs: JobsBackend) -> String {
293    let mut lines = vec![
294        "      MAILER__TYPE: smtp",
295        "      MAILER__SMTP__ADDRESS: mailpit:1025",
296    ];
297    match cache {
298        CacheBackend::Redis => lines.push("      CACHE__ENDPOINT: redis://redis:6379"),
299        CacheBackend::Memcache => lines.push("      CACHE__ENDPOINT: memcache://memcache:11211"),
300        CacheBackend::Memory => {}
301    }
302    if jobs == JobsBackend::Redis {
303        lines.push("      JOBS__REDIS__URL: redis://redis:6379");
304    }
305    if lines.is_empty() {
306        String::new()
307    } else {
308        lines.join("\n")
309    }
310}
311
312fn compose_web_volumes(database: &str) -> String {
313    if database == "sqlite" {
314        "      - ./db:/app/db\n".to_string()
315    } else {
316        String::new()
317    }
318}
319
320fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
321    let (cable_deps, cable_module, cable_readme) = if ctx.cable {
322        (
323            format!(
324                "doido-cable = {}\nasync-trait = \"0.1\"\n",
325                doido_dependency(&ctx.dep_mode, "doido-cable", "")
326            ),
327            CABLE_MODULE_INCLUDE.to_string(),
328            CABLE_README_SECTION.replace("{doido_name}", ctx.name),
329        )
330    } else {
331        (String::new(), String::new(), String::new())
332    };
333
334    let doido_auth_deps = if ctx.auth {
335        format!(
336            "doido-auth = {}\nchrono = {{ version = \"0.4\", features = [\"clock\"] }}\n",
337            doido_dependency(&ctx.dep_mode, "doido-auth", "")
338        )
339    } else {
340        String::new()
341    };
342
343    template
344        .replace("{doido_name}", ctx.name)
345        .replace("{doido_db_url_test}", &ctx.db_url_test)
346        .replace("{doido_db_url_production}", &ctx.db_url_production)
347        .replace("{doido_db_url}", &ctx.db_url)
348        .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
349        .replace("{doido_dep}", &ctx.doido_dep)
350        .replace("{doido_migration_dep}", &ctx.doido_migration_dep)
351        .replace(
352            "{doido_core_dep}",
353            &doido_dependency(&ctx.dep_mode, "doido-core", ""),
354        )
355        .replace(
356            "{doido_controller_dep}",
357            &doido_dependency(
358                &ctx.dep_mode,
359                "doido-controller",
360                &doido_model_features(ctx.sqlx_feature),
361            ),
362        )
363        .replace("{doido_jobs_dep}", &ctx.doido_jobs_dep)
364        .replace(
365            "{doido_mailer_dep}",
366            &doido_dependency(&ctx.dep_mode, "doido-mailer", ""),
367        )
368        .replace("{doido_model_dep}", &ctx.doido_model_dep)
369        .replace("{doido_cable_deps}", &cable_deps)
370        .replace("{doido_auth_deps}", &doido_auth_deps)
371        .replace("{doido_channels_module}", &cable_module)
372        .replace("{doido_cable_readme}", &cable_readme)
373        .replace("{doido_cache_section}", &ctx.cache_section)
374        .replace("{doido_jobs_section}", &ctx.jobs_section)
375        .replace("{doido_storage_section}", &ctx.storage_section)
376        .replace("{doido_mailer_section}", MAILER_DEV_SECTION)
377        .replace("{doido_compose_services}", &ctx.compose_services)
378        .replace("{doido_compose_depends_on}", &ctx.compose_depends_on)
379        .replace("{doido_compose_database_url}", &ctx.compose_database_url)
380        .replace("{doido_compose_env_extras}", &ctx.compose_env_extras)
381        .replace("{doido_compose_web_volumes}", &ctx.compose_web_volumes)
382        .replace("{doido_path}", &ctx.dep_mode.workspace_path)
383}
384
385fn collect_from_dir(
386    dir: &Dir<'_>,
387    ctx: &TemplateContext<'_>,
388    app_name: &str,
389    out: &mut Vec<GeneratedFile>,
390) -> Result<()> {
391    for entry in dir.entries() {
392        match entry {
393            DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
394            DirEntry::File(f) => {
395                let relative = f.path();
396                if !ctx.cable && relative.starts_with(CABLE_TEMPLATE_PREFIX) {
397                    continue;
398                }
399                let raw = f.contents_utf8().ok_or_else(|| {
400                    anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
401                })?;
402                let rendered = substitute_template(raw, ctx);
403                let relative = relative.to_string_lossy().replace('\\', "/");
404                let relative = relative.strip_suffix(".template").unwrap_or(&relative);
405                let disk_path = format!("{app_name}/{relative}");
406                out.push(GeneratedFile {
407                    path: disk_path,
408                    content: rendered,
409                });
410            }
411        }
412    }
413    Ok(())
414}
415
416struct DbDefaults {
417    scheme: &'static str,
418    user: &'static str,
419    password: &'static str,
420    port: u16,
421}
422
423fn db_defaults(backend: &str) -> Option<DbDefaults> {
424    match backend {
425        "postgres" => Some(DbDefaults {
426            scheme: "postgres",
427            user: "postgres",
428            password: "postgres",
429            port: 5432,
430        }),
431        "mysql" => Some(DbDefaults {
432            scheme: "mysql",
433            user: "root",
434            password: "password",
435            port: 3306,
436        }),
437        _ => None,
438    }
439}
440
441fn default_database_url(backend: &str, name: &str, env: &str) -> String {
442    match db_defaults(backend) {
443        Some(d) => {
444            let password = if env == "production" {
445                "CHANGE_ME"
446            } else {
447                d.password
448            };
449            format!(
450                "{}://{}:{}@localhost:{}/{}_{}",
451                d.scheme, d.user, password, d.port, name, env
452            )
453        }
454        None => format!("sqlite://db/{env}.db"),
455    }
456}
457
458pub struct ProjectGenerator;
459
460impl Generator for ProjectGenerator {
461    fn name(&self) -> &str {
462        "new"
463    }
464
465    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
466        let name = args
467            .first()
468            .copied()
469            .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
470
471        let database = parse_database(flag_value(args, "--database=", "sqlite"))?;
472        let cache = parse_cache(flag_value(args, "--cache=", "memory"))?;
473        let jobs = parse_jobs(flag_value(args, "--jobs=", "memory"))?;
474        let cable = args.contains(&"--cable");
475        let auth = args.contains(&"--auth");
476
477        let database = database.as_str();
478        let db_url = default_database_url(database, name, "development");
479        let db_url_test = default_database_url(database, name, "test");
480        let db_url_production = default_database_url(database, name, "production");
481
482        let sqlx_feature = match database {
483            "postgres" => "postgres",
484            "mysql" => "mysql",
485            _ => "sqlite",
486        };
487
488        let dep_mode = DependencyMode::resolve();
489
490        let ctx = TemplateContext {
491            name,
492            db_url,
493            db_url_test,
494            db_url_production,
495            sqlx_feature,
496            cable,
497            auth,
498            doido_dep: doido_dependency(&dep_mode, "doido", &doido_features(cache, database)),
499            doido_migration_dep: doido_dependency(
500                &dep_mode,
501                "doido",
502                &doido_migration_features(database),
503            ),
504            doido_jobs_dep: doido_dependency(
505                &dep_mode,
506                "doido-jobs",
507                &doido_jobs_features(jobs, database),
508            ),
509            doido_model_dep: doido_dependency(
510                &dep_mode,
511                "doido-model",
512                &doido_model_features(database),
513            ),
514            dep_mode,
515            cache_section: render_cache_section(cache, name),
516            jobs_section: render_jobs_section(jobs, name),
517            storage_section: storage_config_section("local"),
518            compose_services: compose_services(database, name, cable, cache, jobs),
519            compose_depends_on: compose_depends_on(database, cable, cache, jobs),
520            compose_database_url: compose_database_url_for_docker(database, name),
521            compose_env_extras: compose_env_extras(cache, jobs),
522            compose_web_volumes: compose_web_volumes(database),
523        };
524
525        let mut files = Vec::new();
526        collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
527
528        if let Some(lib) = files
529            .iter_mut()
530            .find(|f| f.path.ends_with("db/migration/src/lib.rs"))
531        {
532            let (updated, migrations) =
533                apply_bootstrap_migrations(&lib.content, jobs == JobsBackend::Db);
534            lib.content = updated;
535            for (module, content) in migrations {
536                files.push(GeneratedFile {
537                    path: format!("{name}/db/migration/src/{module}.rs"),
538                    content,
539                });
540            }
541        }
542
543        files.sort_by(|a, b| a.path.cmp(&b.path));
544        Ok(files)
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use crate::new_options::{parse_cache, parse_jobs, JobsBackend};
552
553    #[test]
554    fn local_builds_emit_path_dependencies() {
555        assert_eq!(
556            dependency_spec(true, "/home/dev/doido", "0.0.6", "doido", ""),
557            "{ path = \"/home/dev/doido/doido\" }"
558        );
559    }
560
561    #[test]
562    fn published_builds_emit_version_dependencies() {
563        assert_eq!(
564            dependency_spec(false, "/irrelevant", "0.0.6", "doido", ""),
565            "{ version = \"0.0.6\" }"
566        );
567    }
568
569    #[test]
570    fn path_dependency_with_features_stays_inside_inline_table() {
571        assert_eq!(
572            dependency_spec(
573                true,
574                "/home/dev/doido",
575                "0.0.6",
576                "doido",
577                ", features = [\"cache-redis\"]",
578            ),
579            "{ path = \"/home/dev/doido/doido\", features = [\"cache-redis\"] }"
580        );
581    }
582
583    #[test]
584    fn published_dependency_with_features_stays_inside_inline_table() {
585        assert_eq!(
586            dependency_spec(
587                false,
588                "/irrelevant",
589                "0.0.6",
590                "doido-jobs",
591                ", features = [\"jobs-redis\"]",
592            ),
593            "{ version = \"0.0.6\", features = [\"jobs-redis\"] }"
594        );
595    }
596
597    fn assert_cargo_toml_parses(cargo_toml: &str) {
598        cargo_toml
599            .parse::<toml::Table>()
600            .expect("valid Cargo.toml TOML");
601    }
602
603    fn minimal_cargo_with_doido_line(doido_line: &str) -> String {
604        format!(
605            r#"[package]
606name = "app"
607version = "0.1.0"
608edition = "2021"
609
610[dependencies]
611{doido_line}
612"#
613        )
614    }
615
616    #[test]
617    fn doido_features_include_database_and_cache() {
618        assert_eq!(
619            doido_features(CacheBackend::Memory, "postgres"),
620            ", default-features = false, features = [\"postgres\"]"
621        );
622        assert_eq!(
623            doido_features(CacheBackend::Redis, "sqlite"),
624            ", default-features = false, features = [\"sqlite\", \"cache-redis\"]"
625        );
626    }
627
628    #[test]
629    fn published_cache_redis_line_is_valid_toml() {
630        let line = format!(
631            "doido = {}",
632            dependency_spec(
633                false,
634                "/irrelevant",
635                "0.0.9",
636                "doido",
637                &doido_features(CacheBackend::Redis, "sqlite"),
638            )
639        );
640        assert!(!line.contains("path ="));
641        assert!(line.contains("version = \"0.0.9\""));
642        assert!(line.contains("cache-redis"));
643        assert!(line.contains("sqlite"));
644        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
645    }
646
647    #[test]
648    fn doido_migration_features_include_database_and_cli() {
649        assert_eq!(
650            doido_migration_features("sqlite"),
651            ", default-features = false, features = [\"sqlite\", \"cli\"]"
652        );
653        assert_eq!(
654            doido_migration_features("postgres"),
655            ", default-features = false, features = [\"postgres\", \"cli\"]"
656        );
657    }
658
659    #[test]
660    fn doido_model_features_match_database_backend() {
661        assert_eq!(doido_model_features("sqlite"), ", features = [\"sqlite\"]");
662        assert_eq!(
663            doido_model_features("postgres"),
664            ", features = [\"postgres\"]"
665        );
666        assert_eq!(doido_model_features("mysql"), ", features = [\"mysql\"]");
667    }
668
669    #[test]
670    fn doido_jobs_db_features_include_database_driver() {
671        assert_eq!(
672            doido_jobs_features(JobsBackend::Db, "postgres"),
673            ", features = [\"jobs-db\", \"postgres\"]"
674        );
675        assert_eq!(
676            doido_jobs_features(JobsBackend::Redis, "sqlite"),
677            ", features = [\"jobs-redis\"]"
678        );
679        assert_eq!(doido_jobs_features(JobsBackend::Memory, "mysql"), "");
680    }
681
682    #[test]
683    fn path_doido_model_postgres_line_is_valid_toml() {
684        let line = format!(
685            "doido-model = {}",
686            dependency_spec(
687                true,
688                "/home/dev/doido",
689                "0.0.9",
690                "doido-model",
691                ", features = [\"postgres\"]",
692            )
693        );
694        assert!(line.contains("path = \"/home/dev/doido/doido-model\""));
695        assert!(line.contains("postgres"));
696        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
697    }
698
699    #[test]
700    fn path_jobs_redis_line_is_valid_toml() {
701        let line = format!(
702            "doido-jobs = {}",
703            dependency_spec(
704                true,
705                "/home/dev/doido",
706                "0.0.9",
707                "doido-jobs",
708                ", features = [\"jobs-redis\"]",
709            )
710        );
711        assert!(line.contains("path = \"/home/dev/doido/doido-jobs\""));
712        assert!(line.contains("jobs-redis"));
713        assert_cargo_toml_parses(&minimal_cargo_with_doido_line(&line));
714    }
715
716    #[test]
717    fn postgres_url_has_default_user_password_and_port() {
718        assert_eq!(
719            default_database_url("postgres", "blog", "development"),
720            "postgres://postgres:postgres@localhost:5432/blog_development"
721        );
722    }
723
724    #[test]
725    fn production_password_is_a_placeholder() {
726        assert_eq!(
727            default_database_url("postgres", "blog", "production"),
728            "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
729        );
730    }
731
732    #[test]
733    fn sqlite_stays_a_bare_file_path() {
734        assert_eq!(
735            default_database_url("sqlite", "blog", "development"),
736            "sqlite://db/development.db"
737        );
738    }
739
740    #[test]
741    fn parse_cache_accepts_memcached_alias() {
742        assert_eq!(parse_cache("memcached").unwrap(), CacheBackend::Memcache);
743    }
744
745    #[test]
746    fn parse_jobs_accepts_database_alias() {
747        assert_eq!(parse_jobs("database").unwrap(), JobsBackend::Db);
748    }
749
750    #[test]
751    fn compose_includes_redis_for_cache_redis() {
752        let svc = compose_services(
753            "sqlite",
754            "app",
755            false,
756            CacheBackend::Redis,
757            JobsBackend::Memory,
758        );
759        assert!(svc.contains("redis:8-alpine"));
760        assert!(!svc.contains("postgres:"));
761    }
762
763    #[test]
764    fn compose_includes_memcache_for_cache_memcache() {
765        let svc = compose_services(
766            "sqlite",
767            "app",
768            false,
769            CacheBackend::Memcache,
770            JobsBackend::Memory,
771        );
772        assert!(svc.contains("memcached:1.6-alpine"));
773        assert!(!svc.contains("redis:"));
774    }
775
776    #[test]
777    fn compose_deduplicates_redis_when_cable_and_jobs_redis() {
778        let svc = compose_services(
779            "sqlite",
780            "app",
781            true,
782            CacheBackend::Memory,
783            JobsBackend::Redis,
784        );
785        assert_eq!(svc.matches("image: redis:").count(), 1);
786    }
787
788    #[test]
789    fn compose_database_url_uses_docker_hostnames() {
790        assert_eq!(
791            compose_database_url_for_docker("postgres", "blog"),
792            "postgres://postgres:postgres@postgres:5432/blog_development"
793        );
794    }
795
796    #[test]
797    fn compose_always_includes_mailpit() {
798        let svc = compose_services(
799            "sqlite",
800            "app",
801            false,
802            CacheBackend::Memory,
803            JobsBackend::Memory,
804        );
805        assert!(svc.contains("mailpit:"));
806        assert!(svc.contains("axllent/mailpit"));
807    }
808
809    #[test]
810    fn compose_env_extras_wires_mailer_to_mailpit() {
811        let env = compose_env_extras(CacheBackend::Memory, JobsBackend::Memory);
812        assert!(env.contains("MAILER__TYPE: smtp"));
813        assert!(env.contains("MAILER__SMTP__ADDRESS: mailpit:1025"));
814    }
815}