Skip to main content

doido_auth/generators/
install.rs

1//! `doido generate auth:install` — the `devise:install` + `devise User` analogue.
2//!
3//! Emits a User migration and model, auth controllers, views (HTML mode),
4//! config snippets, and injects `auth_routes!(User);` into `config/routes.rs`.
5//! Does **not** modify `Cargo.toml`.
6
7use super::migration_support::{
8    register_migration, render_migration_file, MIGRATION_LIB_BASE, MIGRATION_SRC_DIR,
9};
10use super::route_injector::{
11    inject_auth_routes, read_controllers_mod, read_models_mod, read_routes,
12    register_auth_controllers_mod, register_model_module, CONTROLLERS_MOD_PATH, MODELS_MOD_PATH,
13    ROUTES_PATH,
14};
15use super::template;
16use super::{AuthGenerator, GeneratedFile};
17use chrono::Utc;
18use doido_core::Result;
19
20pub struct AuthInstallGenerator;
21
22const IMPORTS: &str = "use doido::model::migration::{create_table, drop_table};";
23
24fn users_up_body(two_factor: bool) -> String {
25    let mut body = String::from(
26        "        create_table(manager, \"users\", |t| {\n\
27         \x20           t.string(\"email\").not_null().unique_key();\n\
28         \x20           t.string(\"password_digest\").not_null();\n",
29    );
30    if two_factor {
31        body.push_str("            t.string(\"two_factor_secret\");\n");
32        body.push_str("            t.boolean(\"two_factor_enabled\").not_null();\n");
33    }
34    body.push_str(
35        "            t.timestamp(\"created_at\").not_null();\n\
36         \x20           t.timestamp(\"updated_at\").not_null();\n\
37         \x20       })\n\
38         \x20       .await\n",
39    );
40    body
41}
42
43const DOWN_BODY: &str = "        drop_table(manager, \"users\").await\n";
44
45fn auth_section(two_factor: bool) -> String {
46    let enabled = if two_factor { "true" } else { "false" };
47    format!(
48        "\nauth:\n  user_model: User\n  strategies:\n    - cookie\n  two_factor:\n    enabled: {enabled}\n    issuer: MyApp\n  routes:\n    prefix: /users\n"
49    )
50}
51
52fn config_file(path: &str, two_factor: bool) -> Option<GeneratedFile> {
53    let existing = std::fs::read_to_string(path).ok()?;
54    if existing.contains("\nauth:") || existing.starts_with("auth:") {
55        return None;
56    }
57    Some(GeneratedFile {
58        path: path.to_string(),
59        content: format!("{}{}", existing.trim_end(), auth_section(two_factor)),
60    })
61}
62
63fn user_model(two_factor: bool) -> String {
64    let _ = two_factor;
65    template("user.rs.template").to_string()
66}
67
68fn user_entity(two_factor: bool) -> String {
69    let two_factor_fields = if two_factor {
70        "    pub two_factor_secret: Option<String>,\n    pub two_factor_enabled: bool,\n"
71    } else {
72        ""
73    };
74    template("user_entity.rs.template").replace("{two_factor_fields}", two_factor_fields)
75}
76
77fn entities_mod(existing: &str) -> String {
78    doido_model::entities::register_entity_module(existing, "users")
79}
80
81fn auth_mod(two_factor: bool) -> String {
82    let oauth_module = "mod oauth_controller;\n";
83    let oauth_use = "pub use oauth_controller::OauthController;\n";
84    let (two_factor_module, two_factor_use) = if two_factor {
85        (
86            "mod two_factor_controller;\n",
87            "pub use two_factor_controller::TwoFactorController;\n",
88        )
89    } else {
90        ("", "")
91    };
92    template("auth/mod.rs.template")
93        .replace("{oauth_module}", oauth_module)
94        .replace("{oauth_use}", oauth_use)
95        .replace("{two_factor_module}", two_factor_module)
96        .replace("{two_factor_use}", two_factor_use)
97}
98
99impl AuthGenerator for AuthInstallGenerator {
100    fn name(&self) -> &str {
101        "auth:install"
102    }
103
104    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
105        let api = args.contains(&"--api");
106        let two_factor = args.contains(&"--two-factor");
107
108        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
109        let migration_module = format!("m{timestamp}_create_users_table");
110        let migration = render_migration_file(
111            &migration_module,
112            IMPORTS,
113            &users_up_body(two_factor),
114            DOWN_BODY,
115        );
116
117        let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
118        let existing =
119            std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
120        let lib = register_migration(&existing, &migration_module);
121
122        let models_mod = register_model_module(&read_models_mod(), "user");
123        let entities_mod_path = "app/models/_entities/mod.rs";
124        let entities_mod_base = std::fs::read_to_string(entities_mod_path).unwrap_or_else(|_| {
125            include_str!("../../templates/new/app/models/_entities/mod.rs").to_string()
126        });
127        let entities_mod = entities_mod(&entities_mod_base);
128        let controllers_mod = register_auth_controllers_mod(&read_controllers_mod());
129        let routes = inject_auth_routes(&read_routes(), api);
130
131        let suffix = if api { "api" } else { "html" };
132
133        let mut files = vec![
134            GeneratedFile {
135                path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
136                content: migration,
137            },
138            GeneratedFile {
139                path: lib_path,
140                content: lib,
141            },
142            GeneratedFile {
143                path: "app/models/_entities/users.rs".to_string(),
144                content: user_entity(two_factor),
145            },
146            GeneratedFile {
147                path: entities_mod_path.to_string(),
148                content: entities_mod,
149            },
150            GeneratedFile {
151                path: "app/models/user.rs".to_string(),
152                content: user_model(two_factor),
153            },
154            GeneratedFile {
155                path: MODELS_MOD_PATH.to_string(),
156                content: models_mod,
157            },
158            GeneratedFile {
159                path: "app/controllers/auth/mod.rs".to_string(),
160                content: auth_mod(two_factor),
161            },
162            GeneratedFile {
163                path: "app/controllers/auth/sessions_controller.rs".to_string(),
164                content: template(&format!("auth/sessions_controller_{suffix}.rs.template"))
165                    .to_string(),
166            },
167            GeneratedFile {
168                path: "app/controllers/auth/registrations_controller.rs".to_string(),
169                content: template(&format!(
170                    "auth/registrations_controller_{suffix}.rs.template"
171                ))
172                .to_string(),
173            },
174            GeneratedFile {
175                path: "app/controllers/auth/passwords_controller.rs".to_string(),
176                content: template(&format!("auth/passwords_controller_{suffix}.rs.template"))
177                    .to_string(),
178            },
179            GeneratedFile {
180                path: "app/controllers/auth/oauth_controller.rs".to_string(),
181                content: template("auth/oauth_controller.rs.template").to_string(),
182            },
183            GeneratedFile {
184                path: CONTROLLERS_MOD_PATH.to_string(),
185                content: controllers_mod,
186            },
187            GeneratedFile {
188                path: ROUTES_PATH.to_string(),
189                content: routes,
190            },
191        ];
192
193        if two_factor {
194            files.push(GeneratedFile {
195                path: "app/controllers/auth/two_factor_controller.rs".to_string(),
196                content: template(&format!("auth/two_factor_controller_{suffix}.rs.template"))
197                    .to_string(),
198            });
199        }
200
201        if !api {
202            for (file, rel) in [
203                ("sign_in", "auth/views/sign_in.html.tera"),
204                ("sign_up", "auth/views/sign_up.html.tera"),
205                ("password_new", "auth/views/password_new.html.tera"),
206                ("password_edit", "auth/views/password_edit.html.tera"),
207            ] {
208                files.push(GeneratedFile {
209                    path: format!("app/views/auth/{file}.html.tera"),
210                    content: template(rel).to_string(),
211                });
212            }
213            if two_factor {
214                files.push(GeneratedFile {
215                    path: "app/views/auth/two_factor.html.tera".to_string(),
216                    content: template("auth/views/two_factor.html.tera").to_string(),
217                });
218            }
219        }
220
221        if let Some(f) = config_file("config/development.yml", two_factor) {
222            files.push(f);
223        }
224        if let Some(f) = config_file("config/test.yml", two_factor) {
225            files.push(f);
226        }
227
228        Ok(files)
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn emits_users_migration_and_routes() {
238        let files = AuthInstallGenerator.generate(&[]).unwrap();
239        let migration = files
240            .iter()
241            .find(|f| f.path.contains("create_users_table"))
242            .expect("users migration");
243        assert!(migration.content.contains("password_digest"));
244        assert!(migration
245            .content
246            .contains("impl MigrationName for Migration"));
247
248        let routes = files
249            .iter()
250            .find(|f| f.path == ROUTES_PATH)
251            .expect("routes.rs");
252        assert!(routes.content.contains("auth_routes!(User"));
253        assert!(routes
254            .content
255            .contains("sessions: auth::SessionsController"));
256        assert!(routes.content.contains("use crate::controllers::auth;"));
257
258        let user = files
259            .iter()
260            .find(|f| f.path == "app/models/user.rs")
261            .expect("user model");
262        assert!(user.content.contains("impl AuthUser for Model"));
263    }
264
265    #[test]
266    fn two_factor_adds_columns_and_controller() {
267        let files = AuthInstallGenerator.generate(&["--two-factor"]).unwrap();
268        let migration = files
269            .iter()
270            .find(|f| f.path.contains("create_users_table"))
271            .unwrap();
272        assert!(migration.content.contains("two_factor_secret"));
273        assert!(files
274            .iter()
275            .any(|f| f.path.ends_with("two_factor_controller.rs")));
276    }
277
278    #[test]
279    fn api_mode_skips_html_views() {
280        let files = AuthInstallGenerator.generate(&["--api"]).unwrap();
281        assert!(!files.iter().any(|f| f.path.contains("app/views/auth/")));
282        let sessions = files
283            .iter()
284            .find(|f| f.path.ends_with("sessions_controller.rs"))
285            .unwrap();
286        assert!(sessions.content.contains("body_json"));
287    }
288}