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