doido_generators/generators/
new.rs1use crate::generator::{GeneratedFile, Generator};
21use doido_core::{anyhow, Result};
22use include_dir::{include_dir, Dir, DirEntry};
23
24static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
26
27const CABLE_TEMPLATE_PREFIX: &str = "app/channels/";
30
31const CABLE_MODULE_INCLUDE: &str = "\n#[path = \"../app/channels/mod.rs\"]\nmod channels;\n";
33
34const CABLE_README_SECTION: &str = r#"
37## Real-time with doido-cable
38
39This app was generated with `--cable`, so it includes:
40
41- the `doido-cable` (and `async-trait`) dependencies in `Cargo.toml`;
42- an example channel at `app/channels/chat_channel.rs`, registered in
43 `app/channels/mod.rs` and wired into the crate via `mod channels;` in
44 `src/main.rs`.
45
46A channel implements the `Channel` trait — `subscribed`, `unsubscribed`, and
47`received` — and broadcasts to other clients through a shared `Cable` handle over
48a pub/sub backend (`MemoryPubSub` by default; Redis/DB are swappable). See the
49`#[tokio::test]` in `app/channels/chat_channel.rs` for a runnable
50subscribe → broadcast → receive round-trip:
51
52```sh
53cargo test --bin {doido_name} chat
54```
55"#;
56
57struct TemplateContext<'a> {
58 name: &'a str,
59 db_url: String,
60 db_url_test: String,
61 db_url_production: String,
62 sqlx_feature: &'a str,
63 cable: bool,
65}
66
67fn doido_dependency(subdir: &str) -> String {
75 dependency_spec(
76 crate::TEMPLATE_USE_PATH_DEPS,
77 crate::TEMPLATE_WORKSPACE_PATH,
78 crate::DOIDO_VERSION,
79 subdir,
80 )
81}
82
83fn dependency_spec(use_path: bool, workspace_path: &str, version: &str, subdir: &str) -> String {
87 if use_path {
88 format!("{{ path = \"{workspace_path}/{subdir}\" }}")
89 } else {
90 format!("\"{version}\"")
91 }
92}
93
94fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
95 let (cable_deps, cable_module, cable_readme) = if ctx.cable {
98 (
99 format!(
100 "doido-cable = {}\nasync-trait = \"0.1\"\n",
101 doido_dependency("doido-cable")
102 ),
103 CABLE_MODULE_INCLUDE.to_string(),
104 CABLE_README_SECTION.replace("{doido_name}", ctx.name),
105 )
106 } else {
107 (String::new(), String::new(), String::new())
108 };
109
110 template
111 .replace("{doido_name}", ctx.name)
112 .replace("{doido_db_url_test}", &ctx.db_url_test)
113 .replace("{doido_db_url_production}", &ctx.db_url_production)
114 .replace("{doido_db_url}", &ctx.db_url)
115 .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
116 .replace("{doido_dep}", &doido_dependency("doido"))
117 .replace("{doido_core_dep}", &doido_dependency("doido-core"))
118 .replace(
119 "{doido_controller_dep}",
120 &doido_dependency("doido-controller"),
121 )
122 .replace("{doido_jobs_dep}", &doido_dependency("doido-jobs"))
123 .replace("{doido_mailer_dep}", &doido_dependency("doido-mailer"))
124 .replace("{doido_model_dep}", &doido_dependency("doido-model"))
125 .replace("{doido_cable_deps}", &cable_deps)
126 .replace("{doido_channels_module}", &cable_module)
127 .replace("{doido_cable_readme}", &cable_readme)
128 .replace("{doido_path}", crate::TEMPLATE_WORKSPACE_PATH)
129}
130
131fn collect_from_dir(
132 dir: &Dir<'_>,
133 ctx: &TemplateContext<'_>,
134 app_name: &str,
135 out: &mut Vec<GeneratedFile>,
136) -> Result<()> {
137 for entry in dir.entries() {
138 match entry {
139 DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
140 DirEntry::File(f) => {
141 let relative = f.path();
144 if !ctx.cable && relative.starts_with(CABLE_TEMPLATE_PREFIX) {
147 continue;
148 }
149 let raw = f.contents_utf8().ok_or_else(|| {
150 anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
151 })?;
152 let rendered = substitute_template(raw, ctx);
153 let relative = relative.to_string_lossy().replace('\\', "/");
158 let relative = relative.strip_suffix(".template").unwrap_or(&relative);
159 let disk_path = format!("{app_name}/{relative}");
160 out.push(GeneratedFile {
161 path: disk_path,
162 content: rendered,
163 });
164 }
165 }
166 }
167 Ok(())
168}
169
170struct DbDefaults {
172 scheme: &'static str,
174 user: &'static str,
176 password: &'static str,
178 port: u16,
180}
181
182fn db_defaults(backend: &str) -> Option<DbDefaults> {
185 match backend {
186 "postgres" => Some(DbDefaults {
187 scheme: "postgres",
188 user: "postgres",
189 password: "postgres",
190 port: 5432,
191 }),
192 "mysql" => Some(DbDefaults {
193 scheme: "mysql",
194 user: "root",
195 password: "password",
196 port: 3306,
197 }),
198 _ => None,
199 }
200}
201
202fn default_database_url(backend: &str, name: &str, env: &str) -> String {
215 match db_defaults(backend) {
216 Some(d) => {
217 let password = if env == "production" {
218 "CHANGE_ME"
219 } else {
220 d.password
221 };
222 format!(
223 "{}://{}:{}@localhost:{}/{}_{}",
224 d.scheme, d.user, password, d.port, name, env
225 )
226 }
227 None => format!("sqlite://db/{env}.db"),
228 }
229}
230
231pub struct ProjectGenerator;
232
233impl Generator for ProjectGenerator {
234 fn name(&self) -> &str {
235 "new"
236 }
237
238 fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
239 let name = args
240 .first()
241 .copied()
242 .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
243
244 let database = args
245 .iter()
246 .find(|a| a.starts_with("--database="))
247 .and_then(|a| a.split_once('=').map(|(_, v)| v))
248 .unwrap_or("sqlite");
249
250 match database {
251 "sqlite" | "postgres" | "mysql" => {}
252 other => {
253 return Err(anyhow::anyhow!(
254 "Unknown database: {}. Use sqlite, postgres, or mysql.",
255 other
256 ));
257 }
258 }
259
260 let db_url = default_database_url(database, name, "development");
261 let db_url_test = default_database_url(database, name, "test");
262 let db_url_production = default_database_url(database, name, "production");
263
264 let sqlx_feature = match database {
265 "postgres" => "postgres",
266 "mysql" => "mysql",
267 _ => "sqlite",
268 };
269
270 let cable = args.contains(&"--cable");
273
274 let ctx = TemplateContext {
275 name,
276 db_url,
277 db_url_test,
278 db_url_production,
279 sqlx_feature,
280 cable,
281 };
282
283 let mut files = Vec::new();
284 collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
285 files.sort_by(|a, b| a.path.cmp(&b.path));
286 Ok(files)
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::{default_database_url, dependency_spec};
293
294 #[test]
295 fn local_builds_emit_path_dependencies() {
296 assert_eq!(
297 dependency_spec(true, "/home/dev/doido", "0.0.6", "doido"),
298 "{ path = \"/home/dev/doido/doido\" }"
299 );
300 assert_eq!(
301 dependency_spec(true, "/home/dev/doido", "0.0.6", "doido-controller"),
302 "{ path = \"/home/dev/doido/doido-controller\" }"
303 );
304 }
305
306 #[test]
307 fn published_builds_emit_version_dependencies() {
308 assert_eq!(
311 dependency_spec(false, "/irrelevant", "0.0.6", "doido"),
312 "\"0.0.6\""
313 );
314 assert_eq!(
315 dependency_spec(false, "/irrelevant", "1.2.3", "doido-model"),
316 "\"1.2.3\""
317 );
318 }
319
320 #[test]
321 fn postgres_url_has_default_user_password_and_port() {
322 assert_eq!(
323 default_database_url("postgres", "blog", "development"),
324 "postgres://postgres:postgres@localhost:5432/blog_development"
325 );
326 }
327
328 #[test]
329 fn mysql_url_has_default_user_password_and_port() {
330 assert_eq!(
331 default_database_url("mysql", "store", "test"),
332 "mysql://root:password@localhost:3306/store_test"
333 );
334 }
335
336 #[test]
337 fn production_password_is_a_placeholder() {
338 assert_eq!(
339 default_database_url("postgres", "blog", "production"),
340 "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
341 );
342 assert_eq!(
343 default_database_url("mysql", "store", "production"),
344 "mysql://root:CHANGE_ME@localhost:3306/store_production"
345 );
346 }
347
348 #[test]
349 fn sqlite_stays_a_bare_file_path() {
350 assert_eq!(
351 default_database_url("sqlite", "blog", "development"),
352 "sqlite://db/development.db"
353 );
354 }
355}