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_fields = if two_factor {
65        "    pub two_factor_secret: Option<String>,\n    pub two_factor_enabled: bool,\n"
66    } else {
67        ""
68    };
69    template("user.rs.template").replace("{two_factor_fields}", two_factor_fields)
70}
71
72fn auth_mod(two_factor: bool) -> String {
73    let oauth_module = "mod oauth_controller;\n";
74    let oauth_use = "pub use oauth_controller::OauthController;\n";
75    let (two_factor_module, two_factor_use) = if two_factor {
76        (
77            "mod two_factor_controller;\n",
78            "pub use two_factor_controller::TwoFactorController;\n",
79        )
80    } else {
81        ("", "")
82    };
83    template("auth/mod.rs.template")
84        .replace("{oauth_module}", oauth_module)
85        .replace("{oauth_use}", oauth_use)
86        .replace("{two_factor_module}", two_factor_module)
87        .replace("{two_factor_use}", two_factor_use)
88}
89
90impl AuthGenerator for AuthInstallGenerator {
91    fn name(&self) -> &str {
92        "auth:install"
93    }
94
95    fn generate(&self, args: &[&str]) -> Result<Vec<GeneratedFile>> {
96        let api = args.contains(&"--api");
97        let two_factor = args.contains(&"--two-factor");
98
99        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
100        let migration_module = format!("m{timestamp}_create_users_table");
101        let migration = render_migration_file(
102            &migration_module,
103            IMPORTS,
104            &users_up_body(two_factor),
105            DOWN_BODY,
106        );
107
108        let lib_path = format!("{MIGRATION_SRC_DIR}/lib.rs");
109        let existing =
110            std::fs::read_to_string(&lib_path).unwrap_or_else(|_| MIGRATION_LIB_BASE.to_string());
111        let lib = register_migration(&existing, &migration_module);
112
113        let models_mod = register_model_module(&read_models_mod(), "user");
114        let controllers_mod = register_auth_controllers_mod(&read_controllers_mod());
115        let routes = inject_auth_routes(&read_routes(), api);
116
117        let suffix = if api { "api" } else { "html" };
118
119        let mut files = vec![
120            GeneratedFile {
121                path: format!("{MIGRATION_SRC_DIR}/{migration_module}.rs"),
122                content: migration,
123            },
124            GeneratedFile {
125                path: lib_path,
126                content: lib,
127            },
128            GeneratedFile {
129                path: "app/models/user.rs".to_string(),
130                content: user_model(two_factor),
131            },
132            GeneratedFile {
133                path: MODELS_MOD_PATH.to_string(),
134                content: models_mod,
135            },
136            GeneratedFile {
137                path: "app/controllers/auth/mod.rs".to_string(),
138                content: auth_mod(two_factor),
139            },
140            GeneratedFile {
141                path: "app/controllers/auth/sessions_controller.rs".to_string(),
142                content: template(&format!("auth/sessions_controller_{suffix}.rs.template"))
143                    .to_string(),
144            },
145            GeneratedFile {
146                path: "app/controllers/auth/registrations_controller.rs".to_string(),
147                content: template(&format!(
148                    "auth/registrations_controller_{suffix}.rs.template"
149                ))
150                .to_string(),
151            },
152            GeneratedFile {
153                path: "app/controllers/auth/passwords_controller.rs".to_string(),
154                content: template(&format!("auth/passwords_controller_{suffix}.rs.template"))
155                    .to_string(),
156            },
157            GeneratedFile {
158                path: "app/controllers/auth/oauth_controller.rs".to_string(),
159                content: template("auth/oauth_controller.rs.template").to_string(),
160            },
161            GeneratedFile {
162                path: CONTROLLERS_MOD_PATH.to_string(),
163                content: controllers_mod,
164            },
165            GeneratedFile {
166                path: ROUTES_PATH.to_string(),
167                content: routes,
168            },
169        ];
170
171        if two_factor {
172            files.push(GeneratedFile {
173                path: "app/controllers/auth/two_factor_controller.rs".to_string(),
174                content: template(&format!("auth/two_factor_controller_{suffix}.rs.template"))
175                    .to_string(),
176            });
177        }
178
179        if !api {
180            for (file, rel) in [
181                ("sign_in", "auth/views/sign_in.html.tera"),
182                ("sign_up", "auth/views/sign_up.html.tera"),
183                ("password_new", "auth/views/password_new.html.tera"),
184                ("password_edit", "auth/views/password_edit.html.tera"),
185            ] {
186                files.push(GeneratedFile {
187                    path: format!("app/views/auth/{file}.html.tera"),
188                    content: template(rel).to_string(),
189                });
190            }
191            if two_factor {
192                files.push(GeneratedFile {
193                    path: "app/views/auth/two_factor.html.tera".to_string(),
194                    content: template("auth/views/two_factor.html.tera").to_string(),
195                });
196            }
197        }
198
199        if let Some(f) = config_file("config/development.yml", two_factor) {
200            files.push(f);
201        }
202        if let Some(f) = config_file("config/test.yml", two_factor) {
203            files.push(f);
204        }
205
206        Ok(files)
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    #[test]
215    fn emits_users_migration_and_routes() {
216        let files = AuthInstallGenerator.generate(&[]).unwrap();
217        let migration = files
218            .iter()
219            .find(|f| f.path.contains("create_users_table"))
220            .expect("users migration");
221        assert!(migration.content.contains("password_digest"));
222        assert!(migration
223            .content
224            .contains("impl MigrationName for Migration"));
225
226        let routes = files
227            .iter()
228            .find(|f| f.path == ROUTES_PATH)
229            .expect("routes.rs");
230        assert!(routes.content.contains("SessionsController::create"));
231        assert!(routes.content.contains("use crate::controllers::auth;"));
232
233        let user = files
234            .iter()
235            .find(|f| f.path == "app/models/user.rs")
236            .expect("user model");
237        assert!(user.content.contains("impl AuthUser for Model"));
238    }
239
240    #[test]
241    fn two_factor_adds_columns_and_controller() {
242        let files = AuthInstallGenerator.generate(&["--two-factor"]).unwrap();
243        let migration = files
244            .iter()
245            .find(|f| f.path.contains("create_users_table"))
246            .unwrap();
247        assert!(migration.content.contains("two_factor_secret"));
248        assert!(files
249            .iter()
250            .any(|f| f.path.ends_with("two_factor_controller.rs")));
251    }
252
253    #[test]
254    fn api_mode_skips_html_views() {
255        let files = AuthInstallGenerator.generate(&["--api"]).unwrap();
256        assert!(!files.iter().any(|f| f.path.contains("app/views/auth/")));
257        let sessions = files
258            .iter()
259            .find(|f| f.path.ends_with("sessions_controller.rs"))
260            .unwrap();
261        assert!(sessions.content.contains("body_json"));
262    }
263}