1use std::fs;
5use std::path::Path;
6
7pub fn init_command() -> clap::Command {
9 clap::Command::new("init")
10 .about("Scaffolding commands")
11 .subcommand(
12 clap::Command::new("module")
13 .about("Create a new module from a template")
14 .arg(clap::Arg::new("module_id").required(true))
15 .arg(
16 clap::Arg::new("style")
17 .long("style")
18 .default_value("convention")
19 .value_parser(["decorator", "convention", "binding"]),
20 )
21 .arg(clap::Arg::new("dir").long("dir").value_name("PATH"))
22 .arg(
23 clap::Arg::new("description")
24 .long("description")
25 .short('d')
26 .default_value("TODO: add description"),
27 ),
28 )
29}
30
31pub fn handle_init(matches: &clap::ArgMatches) {
33 if let Some(("module", sub_m)) = matches.subcommand() {
34 let module_id = sub_m.get_one::<String>("module_id").unwrap();
35 let style = sub_m.get_one::<String>("style").unwrap();
36 let description = sub_m.get_one::<String>("description").unwrap();
37
38 let (prefix, func_name) = match module_id.rfind('.') {
40 Some(pos) => (&module_id[..pos], &module_id[pos + 1..]),
41 None => (module_id.as_str(), module_id.as_str()),
42 };
43
44 match style.as_str() {
45 "decorator" => {
46 let dir = sub_m
47 .get_one::<String>("dir")
48 .map(|s| s.as_str())
49 .unwrap_or("extensions");
50 validate_dir(dir);
51 create_decorator_module(module_id, func_name, description, dir);
52 }
53 "convention" => {
54 let dir = sub_m
55 .get_one::<String>("dir")
56 .map(|s| s.as_str())
57 .unwrap_or("commands");
58 validate_dir(dir);
59 create_convention_module(module_id, prefix, func_name, description, dir);
60 }
61 "binding" => {
62 let dir = sub_m
63 .get_one::<String>("dir")
64 .map(|s| s.as_str())
65 .unwrap_or("bindings");
66 validate_dir(dir);
67 create_binding_module(module_id, prefix, func_name, description, dir);
68 }
69 _ => unreachable!(),
70 }
71 }
72}
73
74fn validate_dir(dir: &str) {
77 let has_dotdot = std::path::Path::new(dir)
78 .components()
79 .any(|c| c == std::path::Component::ParentDir);
80 if has_dotdot {
81 eprintln!("Error: Output directory must not contain '..' path components.");
82 std::process::exit(2);
83 }
84}
85
86fn to_struct_name(func_name: &str) -> String {
88 let mut result = String::new();
89 let mut capitalize_next = true;
90 for ch in func_name.chars() {
91 if ch == '_' {
92 capitalize_next = true;
93 } else if capitalize_next {
94 result.push(ch.to_ascii_uppercase());
95 capitalize_next = false;
96 } else {
97 result.push(ch);
98 }
99 }
100 result.push_str("Module");
101 result
102}
103
104fn create_decorator_module(module_id: &str, func_name: &str, description: &str, dir: &str) {
106 let dir_path = Path::new(dir);
107 fs::create_dir_all(dir_path).unwrap_or_else(|e| {
108 eprintln!(
109 "Error: cannot create directory '{}': {e}",
110 dir_path.display()
111 );
112 std::process::exit(2);
113 });
114
115 let safe_name = module_id.replace('.', "_");
116 let filename = format!("{safe_name}.rs");
117 let filepath = dir_path.join(&filename);
118
119 let struct_name = to_struct_name(func_name);
120
121 let content = format!(
122 "use apcore::module::Module;\n\
123 use apcore::context::Context;\n\
124 use apcore::errors::ModuleError;\n\
125 use async_trait::async_trait;\n\
126 use serde_json::{{json, Value}};\n\
127 \n\
128 /// {description}\n\
129 pub struct {struct_name};\n\
130 \n\
131 #[async_trait]\n\
132 impl Module for {struct_name} {{\n\
133 {i}fn input_schema(&self) -> Value {{\n\
134 {i}{i}json!({{\n\
135 {i}{i}{i}\"type\": \"object\",\n\
136 {i}{i}{i}\"properties\": {{}}\n\
137 {i}{i}}})\n\
138 {i}}}\n\
139 \n\
140 {i}fn output_schema(&self) -> Value {{\n\
141 {i}{i}json!({{\n\
142 {i}{i}{i}\"type\": \"object\",\n\
143 {i}{i}{i}\"properties\": {{\n\
144 {i}{i}{i}{i}\"status\": {{ \"type\": \"string\" }}\n\
145 {i}{i}{i}}}\n\
146 {i}{i}}})\n\
147 {i}}}\n\
148 \n\
149 {i}fn description(&self) -> &str {{\n\
150 {i}{i}\"{description}\"\n\
151 {i}}}\n\
152 \n\
153 {i}async fn execute(\n\
154 {i}{i}&self,\n\
155 {i}{i}_input: Value,\n\
156 {i}{i}_ctx: &Context<Value>,\n\
157 {i}) -> Result<Value, ModuleError> {{\n\
158 {i}{i}// TODO: implement\n\
159 {i}{i}Ok(json!({{ \"status\": \"ok\" }}))\n\
160 {i}}}\n\
161 }}\n",
162 i = " ",
163 );
164
165 fs::write(&filepath, content).unwrap_or_else(|e| {
166 eprintln!("Error: cannot write '{}': {e}", filepath.display());
167 std::process::exit(2);
168 });
169
170 println!("Created {}", filepath.display());
171}
172
173fn create_convention_module(
176 module_id: &str,
177 prefix: &str,
178 func_name: &str,
179 description: &str,
180 dir: &str,
181) {
182 let filepath = if module_id.contains('.') {
188 let parts: Vec<&str> = module_id.split('.').collect();
189 let mut p = Path::new(dir).to_path_buf();
190 for part in &parts[..parts.len() - 1] {
191 p = p.join(part);
192 }
193 p.join(format!("{}.rs", parts[parts.len() - 1]))
194 } else {
195 Path::new(dir).join(format!("{func_name}.rs"))
196 };
197
198 if let Some(parent) = filepath.parent() {
199 fs::create_dir_all(parent).unwrap_or_else(|e| {
200 eprintln!("Error: cannot create directory '{}': {e}", parent.display());
201 std::process::exit(2);
202 });
203 }
204
205 let first_segment = prefix.split('.').next().unwrap_or(prefix);
207 let cli_group_line = if module_id.contains('.') {
208 format!("pub const CLI_GROUP: &str = \"{first_segment}\";\n\n")
209 } else {
210 String::new()
211 };
212
213 let content = format!(
214 "//! {description}\n\
215 \n\
216 {cli_group_line}\
217 use serde_json::{{json, Value}};\n\
218 \n\
219 /// {description}\n\
220 pub fn {func_name}() -> Value {{\n\
221 {i}// TODO: implement\n\
222 {i}json!({{ \"status\": \"ok\" }})\n\
223 }}\n",
224 i = " ",
225 );
226
227 fs::write(&filepath, content).unwrap_or_else(|e| {
228 eprintln!("Error: cannot write '{}': {e}", filepath.display());
229 std::process::exit(2);
230 });
231
232 println!("Created {}", filepath.display());
233}
234
235fn create_binding_module(
238 module_id: &str,
239 prefix: &str,
240 func_name: &str,
241 description: &str,
242 dir: &str,
243) {
244 let dir_path = Path::new(dir);
245 fs::create_dir_all(dir_path).unwrap_or_else(|e| {
246 eprintln!(
247 "Error: cannot create directory '{}': {e}",
248 dir_path.display()
249 );
250 std::process::exit(2);
251 });
252
253 let safe_name = module_id.replace('.', "_");
255 let yaml_filename = format!("{safe_name}.binding.yaml");
256 let yaml_filepath = dir_path.join(&yaml_filename);
257
258 let target = format!("commands.{prefix}:{func_name}");
259 let prefix_underscored = prefix.replace('.', "_");
260
261 let yaml_content = format!(
262 "bindings:\n\
263 {i}- module_id: \"{module_id}\"\n\
264 {i}{i}target: \"{target}\"\n\
265 {i}{i}description: \"{description}\"\n\
266 {i}{i}auto_schema: true\n",
267 i = " ",
268 );
269
270 fs::write(&yaml_filepath, yaml_content).unwrap_or_else(|e| {
271 eprintln!("Error: cannot write '{}': {e}", yaml_filepath.display());
272 std::process::exit(2);
273 });
274
275 println!("Created {}", yaml_filepath.display());
276
277 let rs_filename = format!("{prefix_underscored}.rs");
280 let rs_filepath = Path::new("commands").join(&rs_filename);
281
282 if !rs_filepath.exists() {
284 if let Some(parent) = rs_filepath.parent() {
285 fs::create_dir_all(parent).unwrap_or_else(|e| {
286 eprintln!("Error: cannot create directory '{}': {e}", parent.display());
287 std::process::exit(2);
288 });
289 }
290
291 let rs_content = format!(
292 "use serde_json::{{json, Value}};\n\
293 \n\
294 /// {description}\n\
295 pub fn {func_name}() -> Value {{\n\
296 {i}// TODO: implement\n\
297 {i}json!({{ \"status\": \"ok\" }})\n\
298 }}\n",
299 i = " ",
300 );
301
302 fs::write(&rs_filepath, rs_content).unwrap_or_else(|e| {
303 eprintln!("Error: cannot write '{}': {e}", rs_filepath.display());
304 std::process::exit(2);
305 });
306
307 println!("Created {}", rs_filepath.display());
308 }
309}
310
311#[cfg(test)]
316mod tests {
317 use super::*;
318
319 #[test]
320 fn test_init_command_has_module_subcommand() {
321 let cmd = init_command();
322 let sub = cmd.get_subcommands().find(|c| c.get_name() == "module");
323 assert!(sub.is_some(), "init must have 'module' subcommand");
324 }
325
326 #[test]
327 fn test_init_command_module_has_required_module_id() {
328 let cmd = init_command();
329 let module_cmd = cmd
330 .get_subcommands()
331 .find(|c| c.get_name() == "module")
332 .expect("module subcommand");
333 let arg = module_cmd
334 .get_arguments()
335 .find(|a| a.get_id() == "module_id");
336 assert!(arg.is_some(), "must have module_id arg");
337 assert!(arg.unwrap().is_required_set(), "module_id must be required");
338 }
339
340 #[test]
341 fn test_init_command_module_has_style_flag() {
342 let cmd = init_command();
343 let module_cmd = cmd
344 .get_subcommands()
345 .find(|c| c.get_name() == "module")
346 .expect("module subcommand");
347 let style = module_cmd.get_arguments().find(|a| a.get_id() == "style");
348 assert!(style.is_some(), "must have --style flag");
349 }
350
351 #[test]
352 fn test_init_command_module_has_dir_flag() {
353 let cmd = init_command();
354 let module_cmd = cmd
355 .get_subcommands()
356 .find(|c| c.get_name() == "module")
357 .expect("module subcommand");
358 let dir = module_cmd.get_arguments().find(|a| a.get_id() == "dir");
359 assert!(dir.is_some(), "must have --dir flag");
360 }
361
362 #[test]
363 fn test_init_command_module_has_description_flag() {
364 let cmd = init_command();
365 let module_cmd = cmd
366 .get_subcommands()
367 .find(|c| c.get_name() == "module")
368 .expect("module subcommand");
369 let desc = module_cmd
370 .get_arguments()
371 .find(|a| a.get_id() == "description");
372 assert!(desc.is_some(), "must have --description flag");
373 }
374
375 #[test]
376 fn test_init_command_parses_valid_args() {
377 let cmd = init_command();
378 let result =
379 cmd.try_get_matches_from(vec!["init", "module", "my.module", "--style", "decorator"]);
380 assert!(result.is_ok(), "valid args must parse: {:?}", result.err());
381 }
382}