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                .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
38/// Attach the `init` subcommand to the given command. Returns the command
39/// with the subcommand added.
40///
41/// This mirrors the per-subcommand registrar pattern used by the FE-13
42/// built-in group (see `discovery::register_list_command`,
43/// `system_cmd::register_health_command`, etc.) so the dispatcher can
44/// honor include/exclude filtering on `init` like any other built-in.
45pub(crate) fn register_init_command(cli: clap::Command) -> clap::Command {
46    cli.subcommand(init_command())
47}
48
49/// Handle the `init` subcommand dispatch.
50pub 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        // Parse module_id: split on last dot for prefix/func_name.
58        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
93/// Refuse to overwrite an existing scaffold file unless `--force` was passed.
94/// Exits with code 2 on conflict so CI and shell pipelines can detect it.
95fn 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
105/// Validate that the output directory does not contain `..` path
106/// components, preventing path traversal outside the project directory.
107fn 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
117/// Convert a snake_case name to PascalCase and append "Module".
118fn 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
135/// Create a decorator-style module (Rust file with Module trait).
136fn 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
211/// Create a convention-style module (Rust function with
212/// CLI_GROUP constant).
213fn 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    // Build the file path: prefix parts become subdirectories.
222    // e.g. module_id "ops.deploy" with dir "commands"
223    //   -> "commands/ops/deploy.rs"
224    // e.g. module_id "standalone" with dir "commands"
225    //   -> "commands/standalone.rs"
226    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    // Only emit CLI_GROUP when module_id contains a dot.
245    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
275/// Create a binding-style module (YAML binding + companion Rust
276/// file).
277fn 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    // Write YAML binding file.
295    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    // Companion Rust file: place it in a `commands/` directory that is a
320    // sibling of `dir_path`, so a user passing `--dir my/bindings` gets
321    // their companion at `my/commands/...` instead of leaking it to the
322    // CWD's `./commands/`. When `dir_path` has no parent (e.g. the default
323    // `bindings`), fall back to `./commands` to preserve behavior.
324    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    // The companion file is only seeded once per prefix (multiple bindings
333    // can share the same Rust function module); leave existing user code
334    // alone unless --force was passed.
335    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// -------------------------------------------------------------------
366// Unit tests
367// -------------------------------------------------------------------
368
369#[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        // Verify the init subcommand retains its 'module' sub-subcommand.
447        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}