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