1use console::style;
6use std::fs;
7use std::io;
8use std::path::{Path, PathBuf};
9
10use crate::templates::module as tpl;
11
12pub fn run(name: String, with_migration: bool, no_views: bool, force: bool) {
14 let root = match crate::project::find_project_root(None) {
15 Ok(p) => p,
16 Err(_) => {
17 eprintln!(
18 "{} Cargo.toml not found (are you inside a Ferro project?)",
19 style("Error:").red().bold()
20 );
21 std::process::exit(1);
22 }
23 };
24
25 match run_in(&root, &name, with_migration, no_views, force) {
26 Ok(report) => {
27 for line in &report.created {
28 println!("{} Created {}", style("✓").green(), line.display());
29 }
30 if let Some(updated) = &report.updated_mod {
31 println!("{} Updated {}", style("✓").green(), updated.display());
32 }
33 println!();
34 println!(
35 "Module {} created successfully!",
36 style(&report.snake_name).cyan().bold()
37 );
38 println!();
39 println!("Usage:");
40 println!(
41 " Wire into your router: crate::modules::{}::routes::register(router)",
42 report.snake_name
43 );
44 println!();
45 }
46 Err(RunError::InvalidName(n)) => {
47 eprintln!(
48 "{} '{}' is not a valid module name",
49 style("Error:").red().bold(),
50 n
51 );
52 std::process::exit(1);
53 }
54 Err(RunError::Exists(p)) => {
55 eprintln!(
56 "{} {} already exists (use --force to overwrite)",
57 style("Error:").red().bold(),
58 p.display()
59 );
60 std::process::exit(1);
61 }
62 Err(RunError::Io(e)) => {
63 eprintln!("{} {}", style("Error:").red().bold(), e);
64 std::process::exit(1);
65 }
66 }
67}
68
69pub fn run_in(
71 root: &Path,
72 name: &str,
73 with_migration: bool,
74 no_views: bool,
75 force: bool,
76) -> Result<Report, RunError> {
77 let snake = to_snake_case(name);
78 if !is_valid_identifier(&snake) {
79 return Err(RunError::InvalidName(name.to_string()));
80 }
81
82 let modules_dir = root.join("src/modules");
83 let module_dir = modules_dir.join(&snake);
84 let views_dir = module_dir.join("views");
85
86 let mut planned: Vec<(PathBuf, String)> = Vec::new();
88 planned.push((
89 module_dir.join("controller.rs"),
90 tpl::module_controller_rs(&snake),
91 ));
92 planned.push((module_dir.join("model.rs"), tpl::module_model_rs(&snake)));
93 planned.push((module_dir.join("routes.rs"), tpl::module_routes_rs(&snake)));
94
95 if no_views {
96 planned.push((
97 module_dir.join("mod.rs"),
98 tpl::module_mod_rs_headless(&snake),
99 ));
100 } else {
101 planned.push((module_dir.join("mod.rs"), tpl::module_mod_rs(&snake)));
102 planned.push((views_dir.join("mod.rs"), tpl::module_views_mod_rs()));
103 planned.push((
104 views_dir.join("index.rs"),
105 tpl::module_view_index_rs(&snake),
106 ));
107 }
108
109 if !force {
111 for (path, _) in &planned {
112 if path.exists() {
113 return Err(RunError::Exists(path.clone()));
114 }
115 }
116 }
117
118 fs::create_dir_all(&module_dir).map_err(RunError::Io)?;
120 if !no_views {
121 fs::create_dir_all(&views_dir).map_err(RunError::Io)?;
122 }
123
124 let mut created: Vec<PathBuf> = Vec::new();
126 for (path, content) in &planned {
127 if let Some(parent) = path.parent() {
128 fs::create_dir_all(parent).map_err(RunError::Io)?;
129 }
130 fs::write(path, content).map_err(RunError::Io)?;
131 created.push(path.clone());
132 }
133
134 let modules_mod = modules_dir.join("mod.rs");
136 let updated_mod = update_modules_mod(&modules_mod, &snake)?;
137
138 if with_migration {
140 let migration_src = root.join("migration/src");
141 if migration_src.is_dir() {
142 let ts = current_timestamp();
143 let file = migration_src.join(format!("m_{ts}_create_{snake}.rs"));
144 if !file.exists() || force {
145 fs::write(&file, tpl::module_migration_rs(&snake, &ts)).map_err(RunError::Io)?;
146 created.push(file);
147 }
148 }
149 }
150
151 Ok(Report {
152 snake_name: snake,
153 created,
154 updated_mod,
155 })
156}
157
158#[derive(Debug)]
159pub struct Report {
160 pub snake_name: String,
161 pub created: Vec<PathBuf>,
162 pub updated_mod: Option<PathBuf>,
163}
164
165#[derive(Debug)]
166pub enum RunError {
167 InvalidName(String),
168 Exists(PathBuf),
169 Io(io::Error),
170}
171
172fn update_modules_mod(mod_file: &Path, snake: &str) -> Result<Option<PathBuf>, RunError> {
173 let decl = format!("pub mod {snake};");
174 if mod_file.exists() {
175 let content = fs::read_to_string(mod_file).map_err(RunError::Io)?;
176 if content.contains(&decl) {
177 return Ok(None);
178 }
179 let mut new_content = content;
180 if !new_content.ends_with('\n') {
181 new_content.push('\n');
182 }
183 new_content.push_str(&decl);
184 new_content.push('\n');
185 fs::write(mod_file, new_content).map_err(RunError::Io)?;
186 Ok(Some(mod_file.to_path_buf()))
187 } else {
188 if let Some(parent) = mod_file.parent() {
189 fs::create_dir_all(parent).map_err(RunError::Io)?;
190 }
191 fs::write(mod_file, format!("//! Feature modules\n\n{decl}\n")).map_err(RunError::Io)?;
192 Ok(Some(mod_file.to_path_buf()))
193 }
194}
195
196fn current_timestamp() -> String {
197 chrono::Utc::now().format("%Y%m%d%H%M%S").to_string()
198}
199
200fn is_valid_identifier(name: &str) -> bool {
201 if name.is_empty() {
202 return false;
203 }
204 let mut chars = name.chars();
205 match chars.next() {
206 Some(c) if c.is_alphabetic() || c == '_' => {}
207 _ => return false,
208 }
209 chars.all(|c| c.is_alphanumeric() || c == '_')
210}
211
212fn to_snake_case(s: &str) -> String {
213 let mut result = String::new();
214 for (i, c) in s.chars().enumerate() {
215 if c.is_uppercase() {
216 if i > 0 {
217 result.push('_');
218 }
219 result.push(c.to_lowercase().next().unwrap());
220 } else if c == '-' {
221 result.push('_');
222 } else {
223 result.push(c);
224 }
225 }
226 result
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use tempfile::TempDir;
233
234 fn setup_project(with_migration_dir: bool) -> TempDir {
235 let tmp = TempDir::new().unwrap();
236 fs::write(
237 tmp.path().join("Cargo.toml"),
238 "[package]\nname = \"test\"\n",
239 )
240 .unwrap();
241 fs::create_dir_all(tmp.path().join("src")).unwrap();
242 if with_migration_dir {
243 fs::create_dir_all(tmp.path().join("migration/src")).unwrap();
244 }
245 tmp
246 }
247
248 #[test]
249 fn creates_default_skeleton() {
250 let tmp = setup_project(false);
251 let report = run_in(tmp.path(), "orders", false, false, false).unwrap();
252 assert_eq!(report.snake_name, "orders");
253
254 let base = tmp.path().join("src/modules/orders");
255 for rel in [
256 "mod.rs",
257 "controller.rs",
258 "model.rs",
259 "routes.rs",
260 "views/mod.rs",
261 "views/index.rs",
262 ] {
263 assert!(base.join(rel).exists(), "missing {rel}");
264 }
265
266 let modules_mod = fs::read_to_string(tmp.path().join("src/modules/mod.rs")).unwrap();
267 assert!(modules_mod.contains("pub mod orders;"));
268 }
269
270 #[test]
271 fn no_views_flag_skips_views() {
272 let tmp = setup_project(false);
273 run_in(tmp.path(), "accounts", false, true, false).unwrap();
274 assert!(!tmp.path().join("src/modules/accounts/views").exists());
275 let mod_rs = fs::read_to_string(tmp.path().join("src/modules/accounts/mod.rs")).unwrap();
276 assert!(!mod_rs.contains("pub mod views;"));
277 }
278
279 #[test]
280 fn with_migration_flag_writes_migration() {
281 let tmp = setup_project(true);
282 run_in(tmp.path(), "invoices", true, false, false).unwrap();
283 let entries: Vec<_> = fs::read_dir(tmp.path().join("migration/src"))
284 .unwrap()
285 .filter_map(|e| e.ok())
286 .map(|e| e.file_name().to_string_lossy().to_string())
287 .collect();
288 assert!(
289 entries.iter().any(|n| n.contains("create_invoices")),
290 "migration file not found in {entries:?}"
291 );
292 }
293
294 #[test]
295 fn force_flag_overwrites() {
296 let tmp = setup_project(false);
297 run_in(tmp.path(), "orders", false, false, false).unwrap();
298 let controller = tmp.path().join("src/modules/orders/controller.rs");
299 fs::write(&controller, "// tampered\n").unwrap();
300
301 let err = run_in(tmp.path(), "orders", false, false, false).unwrap_err();
303 assert!(matches!(err, RunError::Exists(_)));
304
305 run_in(tmp.path(), "orders", false, false, true).unwrap();
307 let content = fs::read_to_string(&controller).unwrap();
308 assert!(content.contains("#[handler]"));
309 }
310
311 #[test]
312 fn rejects_invalid_name() {
313 let tmp = setup_project(false);
314 let err = run_in(tmp.path(), "123bad", false, false, false).unwrap_err();
315 assert!(matches!(err, RunError::InvalidName(_)));
316 }
317}