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 .arg(
29 clap::Arg::new("force")
30 .long("force")
31 .short('f')
32 .help("Overwrite existing scaffold files")
33 .action(clap::ArgAction::SetTrue),
34 ),
35 )
36}
37
38pub(crate) fn register_init_command(cli: clap::Command) -> clap::Command {
46 cli.subcommand(init_command())
47}
48
49pub fn handle_init(matches: &clap::ArgMatches) {
51 if let Some(("module", sub_m)) = matches.subcommand() {
52 let module_id = sub_m.get_one::<String>("module_id").unwrap();
53 let style = sub_m.get_one::<String>("style").unwrap();
54 let description = sub_m.get_one::<String>("description").unwrap();
55 let force = sub_m.get_flag("force");
56
57 let (prefix, func_name) = match module_id.rfind('.') {
59 Some(pos) => (&module_id[..pos], &module_id[pos + 1..]),
60 None => (module_id.as_str(), module_id.as_str()),
61 };
62
63 match style.as_str() {
64 "decorator" => {
65 let dir = sub_m
66 .get_one::<String>("dir")
67 .map(|s| s.as_str())
68 .unwrap_or("extensions");
69 validate_dir(dir);
70 create_decorator_module(module_id, func_name, description, dir, force);
71 }
72 "convention" => {
73 let dir = sub_m
74 .get_one::<String>("dir")
75 .map(|s| s.as_str())
76 .unwrap_or("commands");
77 validate_dir(dir);
78 create_convention_module(module_id, prefix, func_name, description, dir, force);
79 }
80 "binding" => {
81 let dir = sub_m
82 .get_one::<String>("dir")
83 .map(|s| s.as_str())
84 .unwrap_or("bindings");
85 validate_dir(dir);
86 create_binding_module(module_id, prefix, func_name, description, dir, force);
87 }
88 _ => unreachable!(),
89 }
90 }
91}
92
93fn guard_overwrite(filepath: &Path, force: bool) {
96 if !force && filepath.exists() {
97 eprintln!(
98 "Error: '{}' already exists. Pass --force to overwrite.",
99 filepath.display()
100 );
101 std::process::exit(2);
102 }
103}
104
105fn validate_dir(dir: &str) {
108 let has_dotdot = std::path::Path::new(dir)
109 .components()
110 .any(|c| c == std::path::Component::ParentDir);
111 if has_dotdot {
112 eprintln!("Error: Output directory must not contain '..' path components.");
113 std::process::exit(2);
114 }
115}
116
117fn to_struct_name(func_name: &str) -> String {
119 let mut result = String::new();
120 let mut capitalize_next = true;
121 for ch in func_name.chars() {
122 if ch == '_' {
123 capitalize_next = true;
124 } else if capitalize_next {
125 result.push(ch.to_ascii_uppercase());
126 capitalize_next = false;
127 } else {
128 result.push(ch);
129 }
130 }
131 result.push_str("Module");
132 result
133}
134
135fn create_decorator_module(
137 module_id: &str,
138 func_name: &str,
139 description: &str,
140 dir: &str,
141 force: bool,
142) {
143 let dir_path = Path::new(dir);
144 fs::create_dir_all(dir_path).unwrap_or_else(|e| {
145 eprintln!(
146 "Error: cannot create directory '{}': {e}",
147 dir_path.display()
148 );
149 std::process::exit(2);
150 });
151
152 let safe_name = module_id.replace('.', "_");
153 let filename = format!("{safe_name}.rs");
154 let filepath = dir_path.join(&filename);
155
156 let struct_name = to_struct_name(func_name);
157
158 let content = format!(
159 "use apcore::module::Module;\n\
160 use apcore::context::Context;\n\
161 use apcore::errors::ModuleError;\n\
162 use async_trait::async_trait;\n\
163 use serde_json::{{json, Value}};\n\
164 \n\
165 /// {description}\n\
166 pub struct {struct_name};\n\
167 \n\
168 #[async_trait]\n\
169 impl Module for {struct_name} {{\n\
170 {i}fn input_schema(&self) -> Value {{\n\
171 {i}{i}json!({{\n\
172 {i}{i}{i}\"type\": \"object\",\n\
173 {i}{i}{i}\"properties\": {{}}\n\
174 {i}{i}}})\n\
175 {i}}}\n\
176 \n\
177 {i}fn output_schema(&self) -> Value {{\n\
178 {i}{i}json!({{\n\
179 {i}{i}{i}\"type\": \"object\",\n\
180 {i}{i}{i}\"properties\": {{\n\
181 {i}{i}{i}{i}\"status\": {{ \"type\": \"string\" }}\n\
182 {i}{i}{i}}}\n\
183 {i}{i}}})\n\
184 {i}}}\n\
185 \n\
186 {i}fn description(&self) -> &str {{\n\
187 {i}{i}\"{description}\"\n\
188 {i}}}\n\
189 \n\
190 {i}async fn execute(\n\
191 {i}{i}&self,\n\
192 {i}{i}_input: Value,\n\
193 {i}{i}_ctx: &Context<Value>,\n\
194 {i}) -> Result<Value, ModuleError> {{\n\
195 {i}{i}// TODO: implement\n\
196 {i}{i}Ok(json!({{ \"status\": \"ok\" }}))\n\
197 {i}}}\n\
198 }}\n",
199 i = " ",
200 );
201
202 guard_overwrite(&filepath, force);
203 fs::write(&filepath, content).unwrap_or_else(|e| {
204 eprintln!("Error: cannot write '{}': {e}", filepath.display());
205 std::process::exit(2);
206 });
207
208 println!("Created {}", filepath.display());
209}
210
211fn create_convention_module(
214 module_id: &str,
215 prefix: &str,
216 func_name: &str,
217 description: &str,
218 dir: &str,
219 force: bool,
220) {
221 let filepath = if module_id.contains('.') {
227 let parts: Vec<&str> = module_id.split('.').collect();
228 let mut p = Path::new(dir).to_path_buf();
229 for part in &parts[..parts.len() - 1] {
230 p = p.join(part);
231 }
232 p.join(format!("{}.rs", parts[parts.len() - 1]))
233 } else {
234 Path::new(dir).join(format!("{func_name}.rs"))
235 };
236
237 if let Some(parent) = filepath.parent() {
238 fs::create_dir_all(parent).unwrap_or_else(|e| {
239 eprintln!("Error: cannot create directory '{}': {e}", parent.display());
240 std::process::exit(2);
241 });
242 }
243
244 let first_segment = prefix.split('.').next().unwrap_or(prefix);
246 let cli_group_line = if module_id.contains('.') {
247 format!("pub const CLI_GROUP: &str = \"{first_segment}\";\n\n")
248 } else {
249 String::new()
250 };
251
252 let content = format!(
253 "//! {description}\n\
254 \n\
255 {cli_group_line}\
256 use serde_json::{{json, Value}};\n\
257 \n\
258 /// {description}\n\
259 pub fn {func_name}() -> Value {{\n\
260 {i}// TODO: implement\n\
261 {i}json!({{ \"status\": \"ok\" }})\n\
262 }}\n",
263 i = " ",
264 );
265
266 guard_overwrite(&filepath, force);
267 fs::write(&filepath, content).unwrap_or_else(|e| {
268 eprintln!("Error: cannot write '{}': {e}", filepath.display());
269 std::process::exit(2);
270 });
271
272 println!("Created {}", filepath.display());
273}
274
275fn create_binding_module(
278 module_id: &str,
279 prefix: &str,
280 func_name: &str,
281 description: &str,
282 dir: &str,
283 force: bool,
284) {
285 let dir_path = Path::new(dir);
286 fs::create_dir_all(dir_path).unwrap_or_else(|e| {
287 eprintln!(
288 "Error: cannot create directory '{}': {e}",
289 dir_path.display()
290 );
291 std::process::exit(2);
292 });
293
294 let safe_name = module_id.replace('.', "_");
296 let yaml_filename = format!("{safe_name}.binding.yaml");
297 let yaml_filepath = dir_path.join(&yaml_filename);
298
299 let target = format!("commands.{prefix}:{func_name}");
300 let prefix_underscored = prefix.replace('.', "_");
301
302 let yaml_content = format!(
303 "bindings:\n\
304 {i}- module_id: \"{module_id}\"\n\
305 {i}{i}target: \"{target}\"\n\
306 {i}{i}description: \"{description}\"\n\
307 {i}{i}auto_schema: true\n",
308 i = " ",
309 );
310
311 guard_overwrite(&yaml_filepath, force);
312 fs::write(&yaml_filepath, yaml_content).unwrap_or_else(|e| {
313 eprintln!("Error: cannot write '{}': {e}", yaml_filepath.display());
314 std::process::exit(2);
315 });
316
317 println!("Created {}", yaml_filepath.display());
318
319 let commands_dir = dir_path
325 .parent()
326 .filter(|p| !p.as_os_str().is_empty())
327 .map(|p| p.join("commands"))
328 .unwrap_or_else(|| Path::new("commands").to_path_buf());
329 let rs_filename = format!("{prefix_underscored}.rs");
330 let rs_filepath = commands_dir.join(&rs_filename);
331
332 if rs_filepath.exists() && !force {
336 return;
337 }
338
339 if let Some(parent) = rs_filepath.parent() {
340 fs::create_dir_all(parent).unwrap_or_else(|e| {
341 eprintln!("Error: cannot create directory '{}': {e}", parent.display());
342 std::process::exit(2);
343 });
344 }
345
346 let rs_content = format!(
347 "use serde_json::{{json, Value}};\n\
348 \n\
349 /// {description}\n\
350 pub fn {func_name}() -> Value {{\n\
351 {i}// TODO: implement\n\
352 {i}json!({{ \"status\": \"ok\" }})\n\
353 }}\n",
354 i = " ",
355 );
356
357 fs::write(&rs_filepath, rs_content).unwrap_or_else(|e| {
358 eprintln!("Error: cannot write '{}': {e}", rs_filepath.display());
359 std::process::exit(2);
360 });
361
362 println!("Created {}", rs_filepath.display());
363}
364
365#[cfg(test)]
370mod tests {
371 use super::*;
372
373 #[test]
374 fn test_init_command_has_module_subcommand() {
375 let cmd = init_command();
376 let sub = cmd.get_subcommands().find(|c| c.get_name() == "module");
377 assert!(sub.is_some(), "init must have 'module' subcommand");
378 }
379
380 #[test]
381 fn test_init_command_module_has_required_module_id() {
382 let cmd = init_command();
383 let module_cmd = cmd
384 .get_subcommands()
385 .find(|c| c.get_name() == "module")
386 .expect("module subcommand");
387 let arg = module_cmd
388 .get_arguments()
389 .find(|a| a.get_id() == "module_id");
390 assert!(arg.is_some(), "must have module_id arg");
391 assert!(arg.unwrap().is_required_set(), "module_id must be required");
392 }
393
394 #[test]
395 fn test_init_command_module_has_style_flag() {
396 let cmd = init_command();
397 let module_cmd = cmd
398 .get_subcommands()
399 .find(|c| c.get_name() == "module")
400 .expect("module subcommand");
401 let style = module_cmd.get_arguments().find(|a| a.get_id() == "style");
402 assert!(style.is_some(), "must have --style flag");
403 }
404
405 #[test]
406 fn test_init_command_module_has_dir_flag() {
407 let cmd = init_command();
408 let module_cmd = cmd
409 .get_subcommands()
410 .find(|c| c.get_name() == "module")
411 .expect("module subcommand");
412 let dir = module_cmd.get_arguments().find(|a| a.get_id() == "dir");
413 assert!(dir.is_some(), "must have --dir flag");
414 }
415
416 #[test]
417 fn test_init_command_module_has_description_flag() {
418 let cmd = init_command();
419 let module_cmd = cmd
420 .get_subcommands()
421 .find(|c| c.get_name() == "module")
422 .expect("module subcommand");
423 let desc = module_cmd
424 .get_arguments()
425 .find(|a| a.get_id() == "description");
426 assert!(desc.is_some(), "must have --description flag");
427 }
428
429 #[test]
430 fn test_init_command_parses_valid_args() {
431 let cmd = init_command();
432 let result =
433 cmd.try_get_matches_from(vec!["init", "module", "my.module", "--style", "decorator"]);
434 assert!(result.is_ok(), "valid args must parse: {:?}", result.err());
435 }
436
437 #[test]
438 fn test_register_init_command_attaches_init() {
439 let root = register_init_command(clap::Command::new("root"));
440 let subs: Vec<&str> = root.get_subcommands().map(|c| c.get_name()).collect();
441 assert!(
442 subs.contains(&"init"),
443 "must have 'init' subcommand, got {subs:?}"
444 );
445
446 let init_sub = root
448 .get_subcommands()
449 .find(|c| c.get_name() == "init")
450 .expect("init subcommand");
451 let nested: Vec<&str> = init_sub.get_subcommands().map(|c| c.get_name()).collect();
452 assert!(
453 nested.contains(&"module"),
454 "init must have 'module' sub-subcommand, got {nested:?}"
455 );
456 }
457}