mielin_cli/commands/
plugin.rs

1//! Plugin management commands
2
3use crate::output::{render_output, MultiFormatDisplay, OutputFormat};
4use crate::plugin::PluginManager;
5use anyhow::Result;
6use clap::Subcommand;
7use comfy_table::{presets::UTF8_FULL, Cell, Color, ContentArrangement, Table};
8use serde::Serialize;
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[derive(Subcommand)]
13pub enum PluginCommands {
14    /// List all installed plugins
15    #[command(visible_aliases = &["ls"])]
16    List,
17
18    /// Show plugin information
19    #[command(visible_aliases = &["show", "details"])]
20    Info {
21        /// Plugin name
22        name: String,
23    },
24
25    /// Install a plugin from a directory
26    #[command(visible_aliases = &["add"])]
27    Install {
28        /// Path to plugin directory
29        path: PathBuf,
30    },
31
32    /// Uninstall a plugin
33    #[command(visible_aliases = &["remove", "rm"])]
34    Uninstall {
35        /// Plugin name
36        name: String,
37
38        /// Skip confirmation
39        #[arg(short = 'y', long)]
40        yes: bool,
41    },
42
43    /// Enable a plugin
44    Enable {
45        /// Plugin name
46        name: String,
47    },
48
49    /// Disable a plugin
50    Disable {
51        /// Plugin name
52        name: String,
53    },
54
55    /// Execute a plugin command
56    #[command(visible_aliases = &["run", "exec"])]
57    Execute {
58        /// Plugin name
59        plugin: String,
60
61        /// Command name
62        command: String,
63
64        /// Arguments as KEY=VALUE pairs
65        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
66        args: Vec<String>,
67    },
68
69    /// Reload all plugins
70    Reload,
71
72    /// Show plugin directory path
73    #[command(visible_aliases = &["dir"])]
74    Directory,
75}
76
77pub async fn handle_plugin_command(cmd: PluginCommands, format: OutputFormat) -> Result<()> {
78    match cmd {
79        PluginCommands::List => list_plugins(format).await,
80        PluginCommands::Info { name } => show_plugin_info(&name, format).await,
81        PluginCommands::Install { path } => install_plugin(&path, format).await,
82        PluginCommands::Uninstall { name, yes } => uninstall_plugin(&name, yes, format).await,
83        PluginCommands::Enable { name } => enable_plugin(&name, format).await,
84        PluginCommands::Disable { name } => disable_plugin(&name, format).await,
85        PluginCommands::Execute {
86            plugin,
87            command,
88            args,
89        } => execute_plugin_command(&plugin, &command, args, format).await,
90        PluginCommands::Reload => reload_plugins(format).await,
91        PluginCommands::Directory => show_plugin_directory(format).await,
92    }
93}
94
95#[derive(Debug, Serialize)]
96struct PluginListEntry {
97    name: String,
98    version: String,
99    description: String,
100    enabled: String,
101    commands: usize,
102}
103
104impl MultiFormatDisplay for Vec<PluginListEntry> {
105    fn to_table(&self) -> Table {
106        let mut table = Table::new();
107        table
108            .load_preset(UTF8_FULL)
109            .set_content_arrangement(ContentArrangement::Dynamic);
110
111        table.set_header(vec![
112            Cell::new("Name").fg(Color::Cyan),
113            Cell::new("Version").fg(Color::Cyan),
114            Cell::new("Description").fg(Color::Cyan),
115            Cell::new("Enabled").fg(Color::Cyan),
116            Cell::new("Commands").fg(Color::Cyan),
117        ]);
118
119        for entry in self {
120            let enabled_cell = if entry.enabled == "Yes" {
121                Cell::new(&entry.enabled).fg(Color::Green)
122            } else {
123                Cell::new(&entry.enabled).fg(Color::Red)
124            };
125
126            table.add_row(vec![
127                Cell::new(&entry.name),
128                Cell::new(&entry.version),
129                Cell::new(&entry.description),
130                enabled_cell,
131                Cell::new(entry.commands.to_string()),
132            ]);
133        }
134
135        table
136    }
137
138    fn to_quiet(&self) -> String {
139        self.iter()
140            .map(|e| e.name.clone())
141            .collect::<Vec<_>>()
142            .join("\n")
143    }
144}
145
146async fn list_plugins(format: OutputFormat) -> Result<()> {
147    let mut manager = PluginManager::new()?;
148    manager.discover_plugins()?;
149
150    let plugins = manager.list_plugins();
151    let entries: Vec<PluginListEntry> = plugins
152        .iter()
153        .map(|p| PluginListEntry {
154            name: p.metadata.name.clone(),
155            version: p.metadata.version.clone(),
156            description: p.metadata.description.clone(),
157            enabled: if p.enabled { "Yes" } else { "No" }.to_string(),
158            commands: p.metadata.commands.len(),
159        })
160        .collect();
161
162    println!("{}", render_output(&entries, format)?);
163    Ok(())
164}
165
166#[derive(Debug, Serialize)]
167struct PluginInfoDisplay {
168    name: String,
169    version: String,
170    description: String,
171    author: String,
172    license: String,
173    enabled: String,
174    commands: Vec<CommandInfo>,
175    dependencies: Vec<String>,
176    min_version: String,
177}
178
179#[derive(Debug, Serialize)]
180struct CommandInfo {
181    name: String,
182    description: String,
183    aliases: String,
184    arguments: usize,
185}
186
187impl MultiFormatDisplay for PluginInfoDisplay {
188    fn to_table(&self) -> Table {
189        let mut table = Table::new();
190        table
191            .load_preset(UTF8_FULL)
192            .set_content_arrangement(ContentArrangement::Dynamic);
193
194        table.add_row(vec![
195            Cell::new("Name").fg(Color::Cyan),
196            Cell::new(&self.name),
197        ]);
198        table.add_row(vec![
199            Cell::new("Version").fg(Color::Cyan),
200            Cell::new(&self.version),
201        ]);
202        table.add_row(vec![
203            Cell::new("Description").fg(Color::Cyan),
204            Cell::new(&self.description),
205        ]);
206        table.add_row(vec![
207            Cell::new("Author").fg(Color::Cyan),
208            Cell::new(&self.author),
209        ]);
210        table.add_row(vec![
211            Cell::new("License").fg(Color::Cyan),
212            Cell::new(&self.license),
213        ]);
214        table.add_row(vec![
215            Cell::new("Enabled").fg(Color::Cyan),
216            Cell::new(&self.enabled),
217        ]);
218        table.add_row(vec![
219            Cell::new("Commands").fg(Color::Cyan),
220            Cell::new(self.commands.len().to_string()),
221        ]);
222
223        if !self.commands.is_empty() {
224            table.add_row(vec![Cell::new("").fg(Color::Cyan), Cell::new("")]);
225            table.add_row(vec![
226                Cell::new("Available Commands")
227                    .fg(Color::Yellow)
228                    .add_attribute(comfy_table::Attribute::Bold),
229                Cell::new(""),
230            ]);
231            for cmd in &self.commands {
232                table.add_row(vec![
233                    Cell::new(format!("  {}", cmd.name)),
234                    Cell::new(&cmd.description),
235                ]);
236            }
237        }
238
239        table
240    }
241
242    fn to_quiet(&self) -> String {
243        format!("{} v{}", self.name, self.version)
244    }
245}
246
247async fn show_plugin_info(name: &str, format: OutputFormat) -> Result<()> {
248    let mut manager = PluginManager::new()?;
249    manager.discover_plugins()?;
250
251    let plugin = manager
252        .get_plugin(name)
253        .ok_or_else(|| anyhow::anyhow!("Plugin not found: {}", name))?;
254
255    let info = PluginInfoDisplay {
256        name: plugin.metadata.name.clone(),
257        version: plugin.metadata.version.clone(),
258        description: plugin.metadata.description.clone(),
259        author: plugin.metadata.author.clone(),
260        license: plugin.metadata.license.clone(),
261        enabled: if plugin.enabled { "Yes" } else { "No" }.to_string(),
262        commands: plugin
263            .metadata
264            .commands
265            .iter()
266            .map(|c| CommandInfo {
267                name: c.name.clone(),
268                description: c.description.clone(),
269                aliases: c.aliases.join(", "),
270                arguments: c.arguments.len(),
271            })
272            .collect(),
273        dependencies: plugin.metadata.dependencies.clone(),
274        min_version: plugin
275            .metadata
276            .min_version
277            .clone()
278            .unwrap_or_else(|| "None".to_string()),
279    };
280
281    println!("{}", render_output(&info, format)?);
282    Ok(())
283}
284
285async fn install_plugin(path: &Path, format: OutputFormat) -> Result<()> {
286    if !path.exists() {
287        anyhow::bail!("Plugin directory not found: {:?}", path);
288    }
289
290    if !path.is_dir() {
291        anyhow::bail!("Path is not a directory: {:?}", path);
292    }
293
294    let mut manager = PluginManager::new()?;
295    manager.install_plugin(path)?;
296
297    if format != OutputFormat::Quiet {
298        println!("✓ Plugin installed successfully");
299    }
300
301    Ok(())
302}
303
304async fn uninstall_plugin(name: &str, yes: bool, format: OutputFormat) -> Result<()> {
305    let mut manager = PluginManager::new()?;
306    manager.discover_plugins()?;
307
308    // Check if plugin exists
309    if manager.get_plugin(name).is_none() {
310        anyhow::bail!("Plugin not found: {}", name);
311    }
312
313    // Confirm uninstall
314    if !yes && format != OutputFormat::Quiet {
315        println!(
316            "Are you sure you want to uninstall plugin '{}'? [y/N]",
317            name
318        );
319        let mut input = String::new();
320        std::io::stdin().read_line(&mut input)?;
321        if !input.trim().eq_ignore_ascii_case("y") {
322            println!("Uninstall cancelled");
323            return Ok(());
324        }
325    }
326
327    manager.uninstall_plugin(name)?;
328
329    if format != OutputFormat::Quiet {
330        println!("✓ Plugin uninstalled successfully");
331    }
332
333    Ok(())
334}
335
336async fn enable_plugin(name: &str, format: OutputFormat) -> Result<()> {
337    let mut manager = PluginManager::new()?;
338    manager.discover_plugins()?;
339
340    manager.enable_plugin(name)?;
341
342    if format != OutputFormat::Quiet {
343        println!("✓ Plugin enabled: {}", name);
344    }
345
346    Ok(())
347}
348
349async fn disable_plugin(name: &str, format: OutputFormat) -> Result<()> {
350    let mut manager = PluginManager::new()?;
351    manager.discover_plugins()?;
352
353    manager.disable_plugin(name)?;
354
355    if format != OutputFormat::Quiet {
356        println!("✓ Plugin disabled: {}", name);
357    }
358
359    Ok(())
360}
361
362async fn execute_plugin_command(
363    plugin: &str,
364    command: &str,
365    args: Vec<String>,
366    format: OutputFormat,
367) -> Result<()> {
368    let mut manager = PluginManager::new()?;
369    manager.discover_plugins()?;
370
371    // Parse arguments (KEY=VALUE format)
372    let mut arguments = HashMap::new();
373    for arg in args {
374        let parts: Vec<&str> = arg.splitn(2, '=').collect();
375        if parts.len() == 2 {
376            arguments.insert(parts[0].to_string(), parts[1].to_string());
377        } else {
378            anyhow::bail!("Invalid argument format: {}. Expected KEY=VALUE", arg);
379        }
380    }
381
382    let result = manager.execute_command(plugin, command, arguments).await?;
383
384    if result.exit_code != 0 {
385        if !result.stderr.is_empty() && format != OutputFormat::Quiet {
386            eprintln!("{}", result.stderr);
387        }
388        anyhow::bail!("Plugin command failed with exit code: {}", result.exit_code);
389    }
390
391    if !result.stdout.is_empty() && format != OutputFormat::Quiet {
392        println!("{}", result.stdout);
393    }
394
395    if let Some(data) = result.data {
396        if format == OutputFormat::Json {
397            println!("{}", serde_json::to_string_pretty(&data)?);
398        } else if format == OutputFormat::Yaml {
399            println!("{}", serde_yaml::to_string(&data)?);
400        }
401    }
402
403    Ok(())
404}
405
406async fn reload_plugins(format: OutputFormat) -> Result<()> {
407    let mut manager = PluginManager::new()?;
408    let count = manager.discover_plugins()?;
409
410    if format != OutputFormat::Quiet {
411        println!("✓ Reloaded {} plugin(s)", count);
412    }
413
414    Ok(())
415}
416
417#[derive(Debug, Serialize)]
418struct PluginDirectoryInfo {
419    path: String,
420    exists: bool,
421}
422
423impl MultiFormatDisplay for PluginDirectoryInfo {
424    fn to_table(&self) -> Table {
425        let mut table = Table::new();
426        table
427            .load_preset(UTF8_FULL)
428            .set_content_arrangement(ContentArrangement::Dynamic);
429
430        table.add_row(vec![
431            Cell::new("Plugin Directory").fg(Color::Cyan),
432            Cell::new(&self.path),
433        ]);
434
435        table.add_row(vec![
436            Cell::new("Exists").fg(Color::Cyan),
437            if self.exists {
438                Cell::new("Yes").fg(Color::Green)
439            } else {
440                Cell::new("No").fg(Color::Red)
441            },
442        ]);
443
444        table
445    }
446
447    fn to_quiet(&self) -> String {
448        self.path.clone()
449    }
450}
451
452async fn show_plugin_directory(format: OutputFormat) -> Result<()> {
453    let plugin_dir = PluginManager::get_plugin_dir()?;
454
455    let info = PluginDirectoryInfo {
456        path: plugin_dir.to_string_lossy().to_string(),
457        exists: plugin_dir.exists(),
458    };
459
460    println!("{}", render_output(&info, format)?);
461    Ok(())
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_plugin_list_entry_serialization() {
470        let entry = PluginListEntry {
471            name: "test-plugin".to_string(),
472            version: "1.0.0".to_string(),
473            description: "Test plugin".to_string(),
474            enabled: "Yes".to_string(),
475            commands: 2,
476        };
477
478        let json = serde_json::to_string(&entry).unwrap();
479        assert!(json.contains("test-plugin"));
480        assert!(json.contains("1.0.0"));
481    }
482
483    #[test]
484    fn test_plugin_info_display() {
485        let info = PluginInfoDisplay {
486            name: "test-plugin".to_string(),
487            version: "1.0.0".to_string(),
488            description: "Test plugin".to_string(),
489            author: "Test Author".to_string(),
490            license: "MIT".to_string(),
491            enabled: "Yes".to_string(),
492            commands: vec![],
493            dependencies: vec![],
494            min_version: "0.1.0".to_string(),
495        };
496
497        let quiet = info.to_quiet();
498        assert_eq!(quiet, "test-plugin v1.0.0");
499    }
500
501    #[test]
502    fn test_plugin_directory_info() {
503        let info = PluginDirectoryInfo {
504            path: "/tmp/plugins".to_string(),
505            exists: false,
506        };
507
508        let quiet = info.to_quiet();
509        assert_eq!(quiet, "/tmp/plugins");
510    }
511}