doido_generators/generators/
new.rs1use crate::generator::{GeneratedFile, Generator};
14use doido_core::{anyhow, Result};
15use include_dir::{include_dir, Dir, DirEntry};
16
17static APP_TEMPLATE_DIR: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/new");
19
20struct TemplateContext<'a> {
21 name: &'a str,
22 db_url: String,
23 db_url_test: String,
24 db_url_production: String,
25 sqlx_feature: &'a str,
26}
27
28fn doido_dependency(subdir: &str) -> String {
36 dependency_spec(
37 crate::TEMPLATE_USE_PATH_DEPS,
38 crate::TEMPLATE_WORKSPACE_PATH,
39 crate::DOIDO_VERSION,
40 subdir,
41 )
42}
43
44fn dependency_spec(use_path: bool, workspace_path: &str, version: &str, subdir: &str) -> String {
48 if use_path {
49 format!("{{ path = \"{workspace_path}/{subdir}\" }}")
50 } else {
51 format!("\"{version}\"")
52 }
53}
54
55fn substitute_template(template: &str, ctx: &TemplateContext<'_>) -> String {
56 template
57 .replace("{doido_name}", ctx.name)
58 .replace("{doido_db_url_test}", &ctx.db_url_test)
59 .replace("{doido_db_url_production}", &ctx.db_url_production)
60 .replace("{doido_db_url}", &ctx.db_url)
61 .replace("{doido_sqlx_feature}", ctx.sqlx_feature)
62 .replace("{doido_dep}", &doido_dependency("doido"))
63 .replace(
64 "{doido_controller_dep}",
65 &doido_dependency("doido-controller"),
66 )
67 .replace("{doido_model_dep}", &doido_dependency("doido-model"))
68 .replace("{doido_path}", crate::TEMPLATE_WORKSPACE_PATH)
69}
70
71fn collect_from_dir(
72 dir: &Dir<'_>,
73 ctx: &TemplateContext<'_>,
74 app_name: &str,
75 out: &mut Vec<GeneratedFile>,
76) -> Result<()> {
77 for entry in dir.entries() {
78 match entry {
79 DirEntry::Dir(sub) => collect_from_dir(sub, ctx, app_name, out)?,
80 DirEntry::File(f) => {
81 let relative = f.path();
84 let raw = f.contents_utf8().ok_or_else(|| {
85 anyhow::anyhow!("template file '{}' is not valid UTF-8", relative.display())
86 })?;
87 let rendered = substitute_template(raw, ctx);
88 let relative = relative.to_string_lossy().replace('\\', "/");
93 let relative = relative.strip_suffix(".template").unwrap_or(&relative);
94 let disk_path = format!("{app_name}/{relative}");
95 out.push(GeneratedFile {
96 path: disk_path,
97 content: rendered,
98 });
99 }
100 }
101 }
102 Ok(())
103}
104
105struct DbDefaults {
107 scheme: &'static str,
109 user: &'static str,
111 password: &'static str,
113 port: u16,
115}
116
117fn db_defaults(backend: &str) -> Option<DbDefaults> {
120 match backend {
121 "postgres" => Some(DbDefaults {
122 scheme: "postgres",
123 user: "postgres",
124 password: "postgres",
125 port: 5432,
126 }),
127 "mysql" => Some(DbDefaults {
128 scheme: "mysql",
129 user: "root",
130 password: "password",
131 port: 3306,
132 }),
133 _ => None,
134 }
135}
136
137fn default_database_url(backend: &str, name: &str, env: &str) -> String {
150 match db_defaults(backend) {
151 Some(d) => {
152 let password = if env == "production" {
153 "CHANGE_ME"
154 } else {
155 d.password
156 };
157 format!(
158 "{}://{}:{}@localhost:{}/{}_{}",
159 d.scheme, d.user, password, d.port, name, env
160 )
161 }
162 None => format!("sqlite://db/{env}.db"),
163 }
164}
165
166pub struct ProjectGenerator;
167
168impl Generator for ProjectGenerator {
169 fn name(&self) -> &str {
170 "new"
171 }
172
173 fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
174 let name = args
175 .first()
176 .copied()
177 .ok_or_else(|| anyhow::anyhow!("new generator requires a name argument"))?;
178
179 let database = args
180 .iter()
181 .find(|a| a.starts_with("--database="))
182 .and_then(|a| a.split_once('=').map(|(_, v)| v))
183 .unwrap_or("sqlite");
184
185 match database {
186 "sqlite" | "postgres" | "mysql" => {}
187 other => {
188 return Err(anyhow::anyhow!(
189 "Unknown database: {}. Use sqlite, postgres, or mysql.",
190 other
191 ));
192 }
193 }
194
195 let db_url = default_database_url(database, name, "development");
196 let db_url_test = default_database_url(database, name, "test");
197 let db_url_production = default_database_url(database, name, "production");
198
199 let sqlx_feature = match database {
200 "postgres" => "postgres",
201 "mysql" => "mysql",
202 _ => "sqlite",
203 };
204
205 let ctx = TemplateContext {
206 name,
207 db_url,
208 db_url_test,
209 db_url_production,
210 sqlx_feature,
211 };
212
213 let mut files = Vec::new();
214 collect_from_dir(&APP_TEMPLATE_DIR, &ctx, name, &mut files)?;
215 files.sort_by(|a, b| a.path.cmp(&b.path));
216 Ok(files)
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::{default_database_url, dependency_spec};
223
224 #[test]
225 fn local_builds_emit_path_dependencies() {
226 assert_eq!(
227 dependency_spec(true, "/home/dev/doido", "0.0.6", "doido"),
228 "{ path = \"/home/dev/doido/doido\" }"
229 );
230 assert_eq!(
231 dependency_spec(true, "/home/dev/doido", "0.0.6", "doido-controller"),
232 "{ path = \"/home/dev/doido/doido-controller\" }"
233 );
234 }
235
236 #[test]
237 fn published_builds_emit_version_dependencies() {
238 assert_eq!(
241 dependency_spec(false, "/irrelevant", "0.0.6", "doido"),
242 "\"0.0.6\""
243 );
244 assert_eq!(
245 dependency_spec(false, "/irrelevant", "1.2.3", "doido-model"),
246 "\"1.2.3\""
247 );
248 }
249
250 #[test]
251 fn postgres_url_has_default_user_password_and_port() {
252 assert_eq!(
253 default_database_url("postgres", "blog", "development"),
254 "postgres://postgres:postgres@localhost:5432/blog_development"
255 );
256 }
257
258 #[test]
259 fn mysql_url_has_default_user_password_and_port() {
260 assert_eq!(
261 default_database_url("mysql", "store", "test"),
262 "mysql://root:password@localhost:3306/store_test"
263 );
264 }
265
266 #[test]
267 fn production_password_is_a_placeholder() {
268 assert_eq!(
269 default_database_url("postgres", "blog", "production"),
270 "postgres://postgres:CHANGE_ME@localhost:5432/blog_production"
271 );
272 assert_eq!(
273 default_database_url("mysql", "store", "production"),
274 "mysql://root:CHANGE_ME@localhost:3306/store_production"
275 );
276 }
277
278 #[test]
279 fn sqlite_stays_a_bare_file_path() {
280 assert_eq!(
281 default_database_url("sqlite", "blog", "development"),
282 "sqlite://db/development.db"
283 );
284 }
285}