Skip to main content

ferro_cli/commands/
make_module.rs

1//! `ferro make:module <name>` — scaffold a feature-module skeleton under
2//! `src/modules/<name>/` following the controller/model/views/routes convention
3//! used by gestiscilo and mkmenu. Codified to stop the pattern from drifting.
4
5use console::style;
6use std::fs;
7use std::io;
8use std::path::{Path, PathBuf};
9
10use crate::templates::module as tpl;
11
12/// Public entry: resolve project root then delegate to `run_in`.
13pub 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
69/// Deterministic, testable core: operates against a fixed project root.
70pub 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    // Collect planned (path, content) pairs.
87    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    // Pre-check: abort before writing anything if any target exists without --force.
110    if !force {
111        for (path, _) in &planned {
112            if path.exists() {
113                return Err(RunError::Exists(path.clone()));
114            }
115        }
116    }
117
118    // Create directories.
119    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    // Write files.
125    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    // Update or create src/modules/mod.rs.
135    let modules_mod = modules_dir.join("mod.rs");
136    let updated_mod = update_modules_mod(&modules_mod, &snake)?;
137
138    // Optional migration stub.
139    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        // Without --force → error.
302        let err = run_in(tmp.path(), "orders", false, false, false).unwrap_err();
303        assert!(matches!(err, RunError::Exists(_)));
304
305        // With --force → controller is rewritten with the template.
306        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}