Skip to main content

ferro_cli/commands/
make_job.rs

1//! make:job command - Generate a new background job
2
3use console::style;
4use std::fs;
5use std::path::Path;
6
7use crate::templates;
8
9pub fn run(name: String) {
10    // Convert to PascalCase for struct name
11    let struct_name = to_pascal_case(&name);
12
13    // Append "Job" suffix if not already present
14    let struct_name = if struct_name.ends_with("Job") {
15        struct_name
16    } else {
17        format!("{struct_name}Job")
18    };
19
20    // Convert to snake_case for file name
21    let file_name = to_snake_case(&struct_name);
22
23    // Validate the resulting name is a valid Rust identifier
24    if !is_valid_identifier(&file_name) {
25        eprintln!(
26            "{} '{}' is not a valid job name",
27            style("Error:").red().bold(),
28            name
29        );
30        std::process::exit(1);
31    }
32
33    let jobs_dir = Path::new("src/jobs");
34    let job_file = jobs_dir.join(format!("{file_name}.rs"));
35    let mod_file = jobs_dir.join("mod.rs");
36
37    // Ensure we're in a Ferro project (check for src directory)
38    if !Path::new("src").exists() {
39        eprintln!(
40            "{} Not in a Ferro project root directory",
41            style("Error:").red().bold()
42        );
43        eprintln!(
44            "{}",
45            style("Make sure you're in a Ferro project directory with a src/ folder.").dim()
46        );
47        std::process::exit(1);
48    }
49
50    // Create jobs directory if it doesn't exist
51    if !jobs_dir.exists() {
52        if let Err(e) = fs::create_dir_all(jobs_dir) {
53            eprintln!(
54                "{} Failed to create jobs directory: {}",
55                style("Error:").red().bold(),
56                e
57            );
58            std::process::exit(1);
59        }
60        println!("{} Created src/jobs/", style("✓").green());
61
62        // Create mod.rs
63        let mod_content = templates::jobs_mod();
64        if let Err(e) = fs::write(&mod_file, mod_content) {
65            eprintln!(
66                "{} Failed to create mod.rs: {}",
67                style("Error:").red().bold(),
68                e
69            );
70            std::process::exit(1);
71        }
72        println!("{} Created src/jobs/mod.rs", style("✓").green());
73    }
74
75    // Check if job file already exists
76    if job_file.exists() {
77        eprintln!(
78            "{} Job '{}' already exists at {}",
79            style("Info:").yellow().bold(),
80            struct_name,
81            job_file.display()
82        );
83        std::process::exit(0);
84    }
85
86    // Check if module is already declared in mod.rs
87    if mod_file.exists() {
88        let mod_content = fs::read_to_string(&mod_file).unwrap_or_default();
89        let mod_decl = format!("mod {file_name};");
90        let pub_mod_decl = format!("pub mod {file_name};");
91        if mod_content.contains(&mod_decl) || mod_content.contains(&pub_mod_decl) {
92            eprintln!(
93                "{} Module '{}' is already declared in src/jobs/mod.rs",
94                style("Info:").yellow().bold(),
95                file_name
96            );
97            std::process::exit(0);
98        }
99    }
100
101    // Generate job file content
102    let job_content = templates::job_template(&file_name, &struct_name);
103
104    // Write job file
105    if let Err(e) = fs::write(&job_file, job_content) {
106        eprintln!(
107            "{} Failed to write job file: {}",
108            style("Error:").red().bold(),
109            e
110        );
111        std::process::exit(1);
112    }
113    println!("{} Created {}", style("✓").green(), job_file.display());
114
115    // Update mod.rs
116    if let Err(e) = update_mod_file(&mod_file, &file_name, &struct_name) {
117        eprintln!(
118            "{} Failed to update mod.rs: {}",
119            style("Error:").red().bold(),
120            e
121        );
122        std::process::exit(1);
123    }
124    println!("{} Updated src/jobs/mod.rs", style("✓").green());
125
126    println!();
127    println!(
128        "Job {} created successfully!",
129        style(&struct_name).cyan().bold()
130    );
131    println!();
132    println!("Next steps:");
133    println!(
134        "  {} Add job data fields and implement handle() in {}",
135        style("1.").dim(),
136        job_file.display()
137    );
138    println!();
139    println!(
140        "  {} Add the jobs module to src/lib.rs or src/main.rs:",
141        style("2.").dim()
142    );
143    println!("     {}", style("mod jobs;").cyan());
144    println!();
145    println!("  {} Dispatch the job in your code:", style("3.").dim());
146    println!(
147        "     {}",
148        style(format!("use crate::jobs::{file_name}::{struct_name};")).cyan()
149    );
150    println!(
151        "     {}",
152        style(format!(
153            "{struct_name} {{ /* fields */ }}.dispatch().await?;"
154        ))
155        .cyan()
156    );
157    println!();
158    println!("  {} Register the job with your worker:", style("4.").dim());
159    println!(
160        "     {}",
161        style(format!("worker.register::<{struct_name}>();")).cyan()
162    );
163    println!();
164}
165
166fn is_valid_identifier(name: &str) -> bool {
167    if name.is_empty() {
168        return false;
169    }
170
171    let mut chars = name.chars();
172
173    // First character must be letter or underscore
174    match chars.next() {
175        Some(c) if c.is_alphabetic() || c == '_' => {}
176        _ => return false,
177    }
178
179    // Rest must be alphanumeric or underscore
180    chars.all(|c| c.is_alphanumeric() || c == '_')
181}
182
183fn to_snake_case(s: &str) -> String {
184    let mut result = String::new();
185    for (i, c) in s.chars().enumerate() {
186        if c.is_uppercase() {
187            if i > 0 {
188                result.push('_');
189            }
190            result.push(c.to_lowercase().next().unwrap());
191        } else {
192            result.push(c);
193        }
194    }
195    result
196}
197
198fn to_pascal_case(s: &str) -> String {
199    let mut result = String::new();
200    let mut capitalize_next = true;
201
202    for c in s.chars() {
203        if c == '_' || c == '-' || c == ' ' {
204            capitalize_next = true;
205        } else if capitalize_next {
206            result.push(c.to_uppercase().next().unwrap());
207            capitalize_next = false;
208        } else {
209            result.push(c);
210        }
211    }
212    result
213}
214
215fn update_mod_file(mod_file: &Path, file_name: &str, struct_name: &str) -> Result<(), String> {
216    let content =
217        fs::read_to_string(mod_file).map_err(|e| format!("Failed to read mod.rs: {e}"))?;
218
219    let pub_mod_decl = format!("pub mod {file_name};");
220    let pub_use_decl = format!("pub use {file_name}::{struct_name};");
221
222    // Find position to insert declarations
223    let lines: Vec<&str> = content.lines().collect();
224
225    // Find the last pub mod declaration line
226    let mut last_pub_mod_idx = None;
227    let mut last_pub_use_idx = None;
228
229    for (i, line) in lines.iter().enumerate() {
230        if line.trim().starts_with("pub mod ") {
231            last_pub_mod_idx = Some(i);
232        }
233        if line.trim().starts_with("pub use ") {
234            last_pub_use_idx = Some(i);
235        }
236    }
237
238    // Build new content
239    let mut new_lines: Vec<String> = Vec::new();
240
241    // If we found existing pub mod declarations, insert after them
242    if let Some(idx) = last_pub_mod_idx {
243        for (i, line) in lines.iter().enumerate() {
244            new_lines.push(line.to_string());
245            if i == idx {
246                new_lines.push(pub_mod_decl.clone());
247            }
248        }
249    } else {
250        // No existing pub mod declarations, add at the end (before empty lines)
251        let mut content_end = lines.len();
252        while content_end > 0 && lines[content_end - 1].trim().is_empty() {
253            content_end -= 1;
254        }
255
256        for (i, line) in lines.iter().enumerate() {
257            new_lines.push(line.to_string());
258            if i == content_end.saturating_sub(1) || (content_end == 0 && i == 0) {
259                new_lines.push(pub_mod_decl.clone());
260            }
261        }
262
263        // If file was empty
264        if lines.is_empty() {
265            new_lines.push(pub_mod_decl.clone());
266        }
267    }
268
269    // Now add pub use declaration if there are existing pub use declarations
270    if last_pub_use_idx.is_some() {
271        // Find the new position of the last pub use after our modification
272        let mut insert_idx = None;
273        for (i, line) in new_lines.iter().enumerate() {
274            if line.trim().starts_with("pub use ") {
275                insert_idx = Some(i);
276            }
277        }
278        if let Some(idx) = insert_idx {
279            new_lines.insert(idx + 1, pub_use_decl);
280        }
281    }
282
283    let new_content = new_lines.join("\n");
284
285    // Ensure file ends with newline
286    let new_content = if new_content.ends_with('\n') {
287        new_content
288    } else {
289        format!("{new_content}\n")
290    };
291
292    fs::write(mod_file, new_content).map_err(|e| format!("Failed to write mod.rs: {e}"))?;
293
294    Ok(())
295}