Skip to main content

apcore_cli/
init_cmd.rs

1// apcore-cli -- Scaffolding commands (init module).
2// Protocol spec: FE-10
3
4use std::fs;
5use std::path::Path;
6
7/// Register the `init` subcommand with its `module` sub-subcommand.
8pub 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
31/// Handle the `init` subcommand dispatch.
32pub 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        // Parse module_id: split on last dot for prefix/func_name.
39        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
74/// Validate that the output directory does not contain `..` path
75/// components, preventing path traversal outside the project directory.
76fn 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
86/// Convert a snake_case name to PascalCase and append "Module".
87fn 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
104/// Create a decorator-style module (Rust file with Module trait).
105fn 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
173/// Create a convention-style module (Rust function with
174/// CLI_GROUP constant).
175fn create_convention_module(
176    module_id: &str,
177    prefix: &str,
178    func_name: &str,
179    description: &str,
180    dir: &str,
181) {
182    // Build the file path: prefix parts become subdirectories.
183    // e.g. module_id "ops.deploy" with dir "commands"
184    //   -> "commands/ops/deploy.rs"
185    // e.g. module_id "standalone" with dir "commands"
186    //   -> "commands/standalone.rs"
187    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    // Only emit CLI_GROUP when module_id contains a dot.
206    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
235/// Create a binding-style module (YAML binding + companion Rust
236/// file).
237fn 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    // Write YAML binding file.
254    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    // Write companion Rust file to
278    // commands/{prefix_with_dots_as_underscores}.rs
279    let rs_filename = format!("{prefix_underscored}.rs");
280    let rs_filepath = Path::new("commands").join(&rs_filename);
281
282    // Only create if it does not already exist.
283    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// -------------------------------------------------------------------
312// Unit tests
313// -------------------------------------------------------------------
314
315#[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}