metarepo_core/
plugin_builder.rs

1use anyhow::Result;
2use clap::{Arg, ArgMatches, Command};
3use std::collections::HashMap;
4
5use crate::{MetaPlugin, RuntimeConfig, BasePlugin};
6
7/// Builder for creating plugins declaratively
8pub struct PluginBuilder {
9    name: String,
10    version: String,
11    description: String,
12    author: String,
13    experimental: bool,
14    commands: Vec<CommandBuilder>,
15    handlers: HashMap<String, Box<dyn Fn(&ArgMatches, &RuntimeConfig) -> Result<()> + Send + Sync>>,
16}
17
18impl PluginBuilder {
19    /// Create a new plugin builder
20    pub fn new(name: impl Into<String>) -> Self {
21        Self {
22            name: name.into(),
23            version: "0.1.0".to_string(),
24            description: String::new(),
25            author: String::new(),
26            experimental: false,
27            commands: Vec::new(),
28            handlers: HashMap::new(),
29        }
30    }
31    
32    /// Set plugin version
33    pub fn version(mut self, version: impl Into<String>) -> Self {
34        self.version = version.into();
35        self
36    }
37    
38    /// Set plugin description
39    pub fn description(mut self, desc: impl Into<String>) -> Self {
40        self.description = desc.into();
41        self
42    }
43    
44    /// Set plugin author
45    pub fn author(mut self, author: impl Into<String>) -> Self {
46        self.author = author.into();
47        self
48    }
49    
50    /// Mark plugin as experimental
51    pub fn experimental(mut self, experimental: bool) -> Self {
52        self.experimental = experimental;
53        self
54    }
55    
56    /// Add a command to the plugin
57    pub fn command(mut self, builder: CommandBuilder) -> Self {
58        self.commands.push(builder);
59        self
60    }
61    
62    /// Add a handler for a specific command
63    pub fn handler<F>(mut self, command: impl Into<String>, handler: F) -> Self
64    where
65        F: Fn(&ArgMatches, &RuntimeConfig) -> Result<()> + Send + Sync + 'static,
66    {
67        self.handlers.insert(command.into(), Box::new(handler));
68        self
69    }
70    
71    /// Build the plugin
72    pub fn build(self) -> BuiltPlugin {
73        BuiltPlugin {
74            name: self.name,
75            version: self.version,
76            description: self.description,
77            author: self.author,
78            experimental: self.experimental,
79            commands: self.commands,
80            handlers: self.handlers,
81        }
82    }
83}
84
85/// A plugin built from the builder
86pub struct BuiltPlugin {
87    name: String,
88    version: String,
89    description: String,
90    author: String,
91    experimental: bool,
92    commands: Vec<CommandBuilder>,
93    handlers: HashMap<String, Box<dyn Fn(&ArgMatches, &RuntimeConfig) -> Result<()> + Send + Sync>>,
94}
95
96impl MetaPlugin for BuiltPlugin {
97    fn name(&self) -> &str {
98        &self.name
99    }
100    
101    fn register_commands(&self, app: Command) -> Command {
102        if self.commands.is_empty() {
103            return app;
104        }
105        
106        let name: &'static str = Box::leak(self.name.clone().into_boxed_str());
107        let desc: &'static str = Box::leak(self.description.clone().into_boxed_str());
108        let vers: &'static str = Box::leak(self.version.clone().into_boxed_str());
109        
110        let mut plugin_cmd = Command::new(name)
111            .about(desc)
112            .version(vers);
113        
114        for cmd_builder in &self.commands {
115            plugin_cmd = plugin_cmd.subcommand(cmd_builder.build());
116        }
117        
118        app.subcommand(plugin_cmd)
119    }
120    
121    fn handle_command(&self, matches: &ArgMatches, config: &RuntimeConfig) -> Result<()> {
122        // Find which subcommand was called
123        if let Some((cmd_name, sub_matches)) = matches.subcommand() {
124            // Look for a handler
125            if let Some(handler) = self.handlers.get(cmd_name) {
126                return handler(sub_matches, config);
127            }
128            
129            // If no handler, show help
130            println!("No handler registered for command: {}", cmd_name);
131        }
132        
133        // Show help if no subcommand
134        let mut help_cmd = self.build_help_command();
135        help_cmd.print_help()?;
136        Ok(())
137    }
138    
139    fn is_experimental(&self) -> bool {
140        self.experimental
141    }
142}
143
144impl BuiltPlugin {
145    fn build_help_command(&self) -> Command {
146        let name: &'static str = Box::leak(self.name.clone().into_boxed_str());
147        let desc: &'static str = Box::leak(self.description.clone().into_boxed_str());
148        let vers: &'static str = Box::leak(self.version.clone().into_boxed_str());
149        
150        let mut plugin_cmd = Command::new(name)
151            .about(desc)
152            .version(vers);
153        
154        for cmd_builder in &self.commands {
155            plugin_cmd = plugin_cmd.subcommand(cmd_builder.build());
156        }
157        
158        plugin_cmd
159    }
160}
161
162impl BasePlugin for BuiltPlugin {
163    fn version(&self) -> Option<&str> {
164        Some(&self.version)
165    }
166    
167    fn description(&self) -> Option<&str> {
168        Some(&self.description)
169    }
170    
171    fn author(&self) -> Option<&str> {
172        Some(&self.author)
173    }
174}
175
176/// Builder for individual commands
177pub struct CommandBuilder {
178    name: String,
179    about: String,
180    long_about: Option<String>,
181    aliases: Vec<String>,
182    args: Vec<ArgBuilder>,
183    subcommands: Vec<CommandBuilder>,
184    allow_external_subcommands: bool,
185}
186
187impl CommandBuilder {
188    /// Create a new command builder
189    pub fn new(name: impl Into<String>) -> Self {
190        Self {
191            name: name.into(),
192            about: String::new(),
193            long_about: None,
194            aliases: Vec::new(),
195            args: Vec::new(),
196            subcommands: Vec::new(),
197            allow_external_subcommands: false,
198        }
199    }
200    
201    /// Set command description
202    pub fn about(mut self, about: impl Into<String>) -> Self {
203        self.about = about.into();
204        self
205    }
206    
207    /// Set long command description
208    pub fn long_about(mut self, long_about: impl Into<String>) -> Self {
209        self.long_about = Some(long_about.into());
210        self
211    }
212    
213    /// Add command alias
214    pub fn alias(mut self, alias: impl Into<String>) -> Self {
215        self.aliases.push(alias.into());
216        self
217    }
218    
219    /// Add command aliases
220    pub fn aliases(mut self, aliases: Vec<String>) -> Self {
221        self.aliases.extend(aliases);
222        self
223    }
224    
225    /// Add an argument
226    pub fn arg(mut self, arg: ArgBuilder) -> Self {
227        self.args.push(arg);
228        self
229    }
230    
231    /// Add a subcommand
232    pub fn subcommand(mut self, cmd: CommandBuilder) -> Self {
233        self.subcommands.push(cmd);
234        self
235    }
236    
237    /// Allow external subcommands (for commands like exec that need to pass through arbitrary commands)
238    pub fn allow_external_subcommands(mut self, allow: bool) -> Self {
239        self.allow_external_subcommands = allow;
240        self
241    }
242    
243    /// Add standard help formatting options (--output-format, --ai)
244    /// Use this for commands that support structured help output
245    pub fn with_help_formatting(mut self) -> Self {
246        // Add output-format option
247        self.args.push(
248            ArgBuilder::new("output-format")
249                .long("output-format")
250                .help("Output format (json, yaml, markdown)")
251                .takes_value(true)
252                .possible_value("json")
253                .possible_value("yaml")
254                .possible_value("markdown")
255        );
256        
257        // Add AI option
258        self.args.push(
259            ArgBuilder::new("ai")
260                .long("ai")
261                .help("Show AI-friendly structured output (same as --output-format=json)")
262                .takes_value(false)  // This will set the action to SetTrue
263        );
264        
265        self
266    }
267    
268    /// Build the clap Command
269    fn build(&self) -> Command {
270        let name: &'static str = Box::leak(self.name.clone().into_boxed_str());
271        let about: &'static str = Box::leak(self.about.clone().into_boxed_str());
272        
273        let mut cmd = Command::new(name)
274            .about(about)
275            .version(env!("CARGO_PKG_VERSION"));
276        
277        if let Some(ref long_about) = self.long_about {
278            let long_about_str: &'static str = Box::leak(long_about.clone().into_boxed_str());
279            cmd = cmd.long_about(long_about_str);
280        }
281        
282        if !self.aliases.is_empty() {
283            let aliases: Vec<&'static str> = self.aliases.iter()
284                .map(|s| Box::leak(s.clone().into_boxed_str()) as &'static str)
285                .collect();
286            cmd = cmd.visible_aliases(aliases);
287        }
288        
289        for arg_builder in &self.args {
290            cmd = cmd.arg(arg_builder.build());
291        }
292        
293        for subcmd_builder in &self.subcommands {
294            cmd = cmd.subcommand(subcmd_builder.build());
295        }
296        
297        if self.allow_external_subcommands {
298            cmd = cmd.allow_external_subcommands(true);
299        }
300        
301        cmd
302    }
303}
304
305/// Builder for arguments
306pub struct ArgBuilder {
307    name: String,
308    short: Option<char>,
309    long: Option<String>,
310    help: Option<String>,
311    required: bool,
312    takes_value: bool,
313    default_value: Option<String>,
314    possible_values: Vec<String>,
315}
316
317impl ArgBuilder {
318    /// Create a new argument builder
319    pub fn new(name: impl Into<String>) -> Self {
320        Self {
321            name: name.into(),
322            short: None,
323            long: None,
324            help: None,
325            required: false,
326            takes_value: false,
327            default_value: None,
328            possible_values: Vec::new(),
329        }
330    }
331    
332    /// Set short flag
333    pub fn short(mut self, short: char) -> Self {
334        self.short = Some(short);
335        self
336    }
337    
338    /// Set long flag
339    pub fn long(mut self, long: impl Into<String>) -> Self {
340        self.long = Some(long.into());
341        self
342    }
343    
344    /// Set help text
345    pub fn help(mut self, help: impl Into<String>) -> Self {
346        self.help = Some(help.into());
347        self
348    }
349    
350    /// Mark as required
351    pub fn required(mut self, required: bool) -> Self {
352        self.required = required;
353        self
354    }
355    
356    /// Set whether argument takes a value
357    pub fn takes_value(mut self, takes: bool) -> Self {
358        self.takes_value = takes;
359        self
360    }
361    
362    /// Set default value
363    pub fn default_value(mut self, value: impl Into<String>) -> Self {
364        self.default_value = Some(value.into());
365        self
366    }
367    
368    /// Add possible value
369    pub fn possible_value(mut self, value: impl Into<String>) -> Self {
370        self.possible_values.push(value.into());
371        self
372    }
373    
374    /// Build the clap Arg
375    fn build(&self) -> Arg {
376        let name: &'static str = Box::leak(self.name.clone().into_boxed_str());
377        let mut arg = Arg::new(name);
378        
379        if let Some(short) = self.short {
380            arg = arg.short(short);
381        }
382        
383        if let Some(ref long) = self.long {
384            let long_str: &'static str = Box::leak(long.clone().into_boxed_str());
385            arg = arg.long(long_str);
386        }
387        
388        if let Some(ref help) = self.help {
389            let help_str: &'static str = Box::leak(help.clone().into_boxed_str());
390            arg = arg.help(help_str);
391        }
392        
393        if self.required {
394            arg = arg.required(true);
395        }
396        
397        if self.takes_value {
398            arg = arg.action(clap::ArgAction::Set);
399        } else {
400            arg = arg.action(clap::ArgAction::SetTrue);
401        }
402        
403        if let Some(ref default) = self.default_value {
404            let default_str: &'static str = Box::leak(default.clone().into_boxed_str());
405            arg = arg.default_value(default_str);
406        }
407        
408        if !self.possible_values.is_empty() {
409            let values: Vec<&'static str> = self.possible_values.iter()
410                .map(|s| Box::leak(s.clone().into_boxed_str()) as &'static str)
411                .collect();
412            arg = arg.value_parser(values);
413        }
414        
415        arg
416    }
417}
418
419/// Convenience function to create a new plugin builder
420pub fn plugin(name: impl Into<String>) -> PluginBuilder {
421    PluginBuilder::new(name)
422}
423
424/// Convenience function to create a new command builder
425pub fn command(name: impl Into<String>) -> CommandBuilder {
426    CommandBuilder::new(name)
427}
428
429/// Convenience function to create a new argument builder
430pub fn arg(name: impl Into<String>) -> ArgBuilder {
431    ArgBuilder::new(name)
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    
438    #[test]
439    fn test_plugin_builder_basic() {
440        let plugin = plugin("test-plugin")
441            .version("1.0.0")
442            .description("Test plugin")
443            .author("Test Author")
444            .experimental(true)
445            .build();
446        
447        assert_eq!(plugin.name(), "test-plugin");
448        assert_eq!(plugin.version(), Some("1.0.0"));
449        assert_eq!(plugin.description(), Some("Test plugin"));
450        assert_eq!(plugin.author(), Some("Test Author"));
451        assert!(plugin.is_experimental());
452    }
453    
454    #[test]
455    fn test_plugin_builder_with_commands() {
456        let test_handler = |_matches: &ArgMatches, _config: &RuntimeConfig| -> Result<()> {
457            Ok(())
458        };
459        
460        let plugin = plugin("test-plugin")
461            .command(
462                command("test-cmd")
463                    .about("Test command")
464                    .arg(
465                        arg("input")
466                            .short('i')
467                            .long("input")
468                            .help("Input file")
469                            .takes_value(true)
470                    )
471            )
472            .handler("test-cmd", test_handler)
473            .build();
474        
475        let app = Command::new("test");
476        let app_with_plugin = plugin.register_commands(app);
477        
478        // Check that the plugin command was added
479        let plugin_cmd = app_with_plugin.find_subcommand("test-plugin");
480        assert!(plugin_cmd.is_some());
481        
482        let plugin_cmd = plugin_cmd.unwrap();
483        let test_cmd = plugin_cmd.find_subcommand("test-cmd");
484        assert!(test_cmd.is_some());
485    }
486    
487    #[test]
488    fn test_command_builder() {
489        let cmd = command("test")
490            .about("Test command")
491            .long_about("This is a longer description")
492            .aliases(vec!["t".to_string(), "tst".to_string()])
493            .arg(
494                arg("verbose")
495                    .short('v')
496                    .long("verbose")
497                    .help("Enable verbose output")
498            )
499            .build();
500        
501        assert_eq!(cmd.get_name(), "test");
502        assert_eq!(cmd.get_about().map(|s| s.to_string()), Some("Test command".to_string()));
503        assert_eq!(cmd.get_long_about().map(|s| s.to_string()), Some("This is a longer description".to_string()));
504        
505        // Check aliases
506        let aliases: Vec<&str> = cmd.get_visible_aliases().map(|a| a).collect();
507        assert!(aliases.contains(&"t"));
508        assert!(aliases.contains(&"tst"));
509        
510        // Check arguments
511        let verbose_arg = cmd.get_arguments().find(|a| a.get_id() == "verbose");
512        assert!(verbose_arg.is_some());
513    }
514    
515    #[test]
516    fn test_arg_builder_flag() {
517        let arg = arg("verbose")
518            .short('v')
519            .long("verbose")
520            .help("Enable verbose output")
521            .build();
522        
523        assert_eq!(arg.get_id().to_string(), "verbose");
524        assert_eq!(arg.get_short(), Some('v'));
525        assert_eq!(arg.get_long(), Some("verbose"));
526        assert_eq!(arg.get_help().map(|s| s.to_string()), Some("Enable verbose output".to_string()));
527    }
528    
529    #[test]
530    fn test_arg_builder_with_value() {
531        let arg = arg("input")
532            .long("input")
533            .help("Input file")
534            .required(true)
535            .takes_value(true)
536            .default_value("default.txt")
537            .build();
538        
539        assert_eq!(arg.get_id().to_string(), "input");
540        assert_eq!(arg.get_long(), Some("input"));
541        assert!(arg.is_required_set());
542        assert_eq!(arg.get_default_values(), &["default.txt"]);
543    }
544    
545    #[test]
546    fn test_arg_builder_with_possible_values() {
547        let arg = arg("format")
548            .long("format")
549            .help("Output format")
550            .takes_value(true)
551            .possible_value("json")
552            .possible_value("yaml")
553            .possible_value("text")
554            .build();
555        
556        assert_eq!(arg.get_id().to_string(), "format");
557        // Note: Testing possible values would require checking the value parser
558        // which is not directly accessible from the Arg struct
559    }
560    
561    #[test]
562    fn test_command_builder_with_subcommands() {
563        let cmd = command("parent")
564            .about("Parent command")
565            .subcommand(
566                command("child1")
567                    .about("First child")
568            )
569            .subcommand(
570                command("child2")
571                    .about("Second child")
572                    .arg(
573                        arg("option")
574                            .long("option")
575                            .takes_value(true)
576                    )
577            )
578            .build();
579        
580        assert_eq!(cmd.get_name(), "parent");
581        
582        let child1 = cmd.find_subcommand("child1");
583        assert!(child1.is_some());
584        assert_eq!(child1.unwrap().get_about().map(|s| s.to_string()), Some("First child".to_string()));
585        
586        let child2 = cmd.find_subcommand("child2");
587        assert!(child2.is_some());
588        let child2 = child2.unwrap();
589        assert_eq!(child2.get_about().map(|s| s.to_string()), Some("Second child".to_string()));
590        
591        let option_arg = child2.get_arguments().find(|a| a.get_id() == "option");
592        assert!(option_arg.is_some());
593    }
594    
595    #[test]
596    fn test_command_builder_external_subcommands() {
597        let cmd = command("exec")
598            .about("Execute commands")
599            .allow_external_subcommands(true)
600            .build();
601        
602        assert_eq!(cmd.get_name(), "exec");
603        assert!(cmd.is_allow_external_subcommands_set());
604    }
605    
606    #[test]
607    fn test_plugin_handler_execution() {
608        use std::sync::Arc;
609        use std::sync::atomic::{AtomicBool, Ordering};
610        
611        let executed = Arc::new(AtomicBool::new(false));
612        let executed_clone = executed.clone();
613        
614        let test_handler = move |_matches: &ArgMatches, _config: &RuntimeConfig| -> Result<()> {
615            executed_clone.store(true, Ordering::SeqCst);
616            Ok(())
617        };
618        
619        let plugin = plugin("test-plugin")
620            .command(
621                command("test-cmd")
622                    .about("Test command")
623            )
624            .handler("test-cmd", test_handler)
625            .build();
626        
627        // Create mock matches for the plugin and its test-cmd subcommand
628        // The plugin expects matches to have the subcommand structure
629        let app = Command::new("test-plugin")
630            .subcommand(Command::new("test-cmd"));
631        let matches = app.clone().get_matches_from(vec!["test-plugin", "test-cmd"]);
632        
633        // Create a dummy runtime config
634        let config = RuntimeConfig {
635            meta_config: crate::MetaConfig::default(),
636            working_dir: std::path::PathBuf::from("."),
637            meta_file_path: None,
638            experimental: false,
639        };
640        
641        // Handle the command - the plugin will look for subcommands in the matches
642        plugin.handle_command(&matches, &config).unwrap();
643        
644        // Check that the handler was executed
645        assert!(executed.load(Ordering::SeqCst));
646    }
647}