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