Skip to main content

ferro_cli/commands/
make_task.rs

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