Skip to main content

meta_plugin_protocol/
lib.rs

1//! Shared plugin protocol types for meta subprocess plugins.
2//!
3//! This crate defines the communication protocol between the meta CLI host
4//! and its subprocess plugins (meta-git, meta-project, meta-rust, etc.).
5//!
6//! The protocol works as follows:
7//! 1. Host discovers plugins via `--meta-plugin-info` (plugin responds with `PluginInfo` JSON)
8//! 2. Host invokes plugins via `--meta-plugin-exec` (sends `PluginRequest` JSON on stdin)
9//! 3. Plugin responds with either a `PlanResponse` JSON (commands to execute) or direct output
10
11use indexmap::IndexMap;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::io::Read;
15
16// ============================================================================
17// Plugin Discovery Types
18// ============================================================================
19
20/// Metadata about a plugin, returned in response to `--meta-plugin-info`.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PluginInfo {
23    pub name: String,
24    pub version: String,
25    pub commands: Vec<String>,
26    #[serde(default)]
27    pub description: Option<String>,
28    #[serde(default)]
29    pub help: Option<PluginHelp>,
30}
31
32/// Help information for a plugin's commands.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PluginHelp {
35    /// Usage string (e.g., "meta git <command> [args...]")
36    pub usage: String,
37    /// Command descriptions (command name -> description)
38    /// Uses IndexMap to preserve insertion order for consistent help output
39    #[serde(default)]
40    pub commands: IndexMap<String, String>,
41    /// Categorized commands (section title -> commands map)
42    /// When present and non-empty, commands are displayed by section instead of flat list
43    #[serde(default)]
44    pub command_sections: IndexMap<String, IndexMap<String, String>>,
45    /// Example usage strings
46    #[serde(default)]
47    pub examples: Vec<String>,
48    /// Additional note (e.g., how to run raw commands)
49    #[serde(default)]
50    pub note: Option<String>,
51}
52
53// ============================================================================
54// Host-to-Plugin Communication
55// ============================================================================
56
57/// A request from the meta CLI host to a plugin, sent as JSON on stdin.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct PluginRequest {
60    pub command: String,
61    #[serde(default)]
62    pub args: Vec<String>,
63    #[serde(default)]
64    pub projects: Vec<String>,
65    #[serde(default)]
66    pub cwd: String,
67    #[serde(default)]
68    pub options: PluginRequestOptions,
69}
70
71/// Options passed from the host to the plugin as part of the request.
72#[derive(Debug, Default, Clone, Serialize, Deserialize)]
73pub struct PluginRequestOptions {
74    #[serde(default)]
75    pub json_output: bool,
76    #[serde(default)]
77    pub verbose: bool,
78    #[serde(default)]
79    pub parallel: bool,
80    #[serde(default)]
81    pub dry_run: bool,
82    #[serde(default)]
83    pub silent: bool,
84    #[serde(default)]
85    pub recursive: bool,
86    #[serde(default)]
87    pub depth: Option<usize>,
88    #[serde(default)]
89    pub include_filters: Option<Vec<String>>,
90    #[serde(default)]
91    pub exclude_filters: Option<Vec<String>>,
92    /// Convert warnings to errors for all-or-nothing behavior (CI/automation)
93    #[serde(default)]
94    pub strict: bool,
95}
96
97// ============================================================================
98// Plugin-to-Host Response
99// ============================================================================
100
101/// An execution plan returned by a plugin, containing commands for the host to execute.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct ExecutionPlan {
104    /// Commands to run BEFORE the main parallel execution (sequential, must complete).
105    /// Use for setup tasks like establishing SSH ControlMaster connections.
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub pre_commands: Vec<PlannedCommand>,
108
109    /// Main commands to execute (may run in parallel based on `parallel` flag)
110    pub commands: Vec<PlannedCommand>,
111
112    /// Commands to run AFTER main execution completes (sequential).
113    /// Use for cleanup tasks.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub post_commands: Vec<PlannedCommand>,
116
117    /// Whether to run main commands in parallel (overrides CLI --parallel if set)
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub parallel: Option<bool>,
120
121    /// Maximum number of commands to run in parallel (pool size limit).
122    /// Use to limit concurrency for operations with shared resources (e.g., SSH
123    /// ControlMaster has a default session limit of 10). When set, loop_lib will
124    /// run at most this many commands simultaneously.
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub max_parallel: Option<usize>,
127
128    /// Milliseconds to wait between spawning parallel commands.
129    /// Use to spread out initial connection bursts and prevent SSH ControlMaster
130    /// socket saturation. A value of 25ms with 13 repos spreads spawns over ~325ms.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub spawn_stagger_ms: Option<u64>,
133}
134
135/// A single command to be executed by the host via loop_lib.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PlannedCommand {
138    /// Directory to execute in (relative to meta root or absolute)
139    pub dir: String,
140    /// Command to execute
141    pub cmd: String,
142    /// Environment variables to set for this command's subprocess
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub env: Option<HashMap<String, String>>,
145}
146
147/// Wrapper for the execution plan response (the JSON envelope plugins emit).
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct PlanResponse {
150    pub plan: ExecutionPlan,
151}
152
153// ============================================================================
154// Command Result
155// ============================================================================
156
157/// The result of a plugin command execution.
158pub enum CommandResult {
159    /// A plan of commands to execute via loop_lib (simple form, no pre/post commands)
160    Plan(Vec<PlannedCommand>, Option<bool>),
161    /// A full execution plan with pre/post commands
162    FullPlan(ExecutionPlan),
163    /// A message to display (no commands to execute)
164    Message(String),
165    /// An error occurred
166    Error(String),
167    /// Show help text (optionally with an error message prefix)
168    ShowHelp(Option<String>),
169}
170
171// ============================================================================
172// Helper Functions
173// ============================================================================
174
175/// Serialize and print an execution plan to stdout.
176pub fn output_execution_plan(commands: Vec<PlannedCommand>, parallel: Option<bool>) {
177    output_execution_plan_full(vec![], commands, vec![], parallel, None, None);
178}
179
180/// Serialize and print a full execution plan with pre/post commands to stdout.
181pub fn output_execution_plan_full(
182    pre_commands: Vec<PlannedCommand>,
183    commands: Vec<PlannedCommand>,
184    post_commands: Vec<PlannedCommand>,
185    parallel: Option<bool>,
186    max_parallel: Option<usize>,
187    spawn_stagger_ms: Option<u64>,
188) {
189    let response = PlanResponse {
190        plan: ExecutionPlan {
191            pre_commands,
192            commands,
193            post_commands,
194            parallel,
195            max_parallel,
196            spawn_stagger_ms,
197        },
198    };
199    println!("{}", serde_json::to_string(&response).unwrap());
200}
201
202/// Read and parse a `PluginRequest` from stdin.
203pub fn read_request_from_stdin() -> anyhow::Result<PluginRequest> {
204    let mut input = String::new();
205    std::io::stdin().read_to_string(&mut input)?;
206    let request: PluginRequest = serde_json::from_str(&input)?;
207    Ok(request)
208}
209
210/// Write plugin help text to a writer.
211fn write_plugin_help(info: &PluginInfo, w: &mut dyn std::io::Write) {
212    if let Some(help) = &info.help {
213        let _ = writeln!(w, "{}", help.usage);
214        let _ = writeln!(w);
215
216        // Use command_sections if present, otherwise fall back to flat commands
217        if !help.command_sections.is_empty() {
218            for (section_title, commands) in &help.command_sections {
219                let _ = writeln!(w, "{section_title}:");
220                for (cmd, desc) in commands {
221                    let _ = writeln!(w, "  {cmd:<20} {desc}");
222                }
223                let _ = writeln!(w);
224            }
225        } else if !help.commands.is_empty() {
226            let _ = writeln!(w, "Commands:");
227            for (cmd, desc) in &help.commands {
228                let _ = writeln!(w, "  {cmd:<20} {desc}");
229            }
230            let _ = writeln!(w);
231        }
232
233        if !help.examples.is_empty() {
234            let _ = writeln!(w, "Examples:");
235            for ex in &help.examples {
236                let _ = writeln!(w, "  {ex}");
237            }
238            let _ = writeln!(w);
239        }
240        if let Some(note) = &help.note {
241            let _ = writeln!(w, "{note}");
242        }
243    } else {
244        let _ = writeln!(w, "meta {} v{}", info.name, info.version);
245        if let Some(desc) = &info.description {
246            let _ = writeln!(w, "{desc}");
247        }
248    }
249}
250
251/// Print plugin help text to stdout.
252fn print_plugin_help(info: &PluginInfo) {
253    write_plugin_help(info, &mut std::io::stdout());
254}
255
256/// Print plugin help text to stderr (for error cases where meta captures stdout).
257fn eprint_plugin_help(info: &PluginInfo) {
258    write_plugin_help(info, &mut std::io::stderr());
259}
260
261// ============================================================================
262// Plugin Harness
263// ============================================================================
264
265/// Definition of a plugin, used by `run_plugin()` to eliminate main.rs boilerplate.
266pub struct PluginDefinition {
267    pub info: PluginInfo,
268    /// The execute function: receives the parsed request and returns a CommandResult.
269    pub execute: fn(PluginRequest) -> CommandResult,
270}
271
272/// Run a plugin's main loop. Handles `--meta-plugin-info` and `--meta-plugin-exec` flags.
273///
274/// This replaces the boilerplate main() function in each plugin binary.
275/// Plugins only need to define their `PluginInfo` and an execute function.
276///
277/// Initializes `env_logger` so plugins can use `log` macros with RUST_LOG.
278pub fn run_plugin(plugin: PluginDefinition) {
279    // Initialize logger for subprocess - inherits RUST_LOG from parent
280    env_logger::init();
281
282    let args: Vec<String> = std::env::args().collect();
283
284    if args.len() < 2 {
285        eprintln!(
286            "This binary is a meta plugin. Use via: meta {}",
287            plugin.info.name
288        );
289        std::process::exit(1);
290    }
291
292    match args[1].as_str() {
293        "--meta-plugin-info" => {
294            let json = serde_json::to_string_pretty(&plugin.info).unwrap();
295            println!("{json}");
296        }
297        "--meta-plugin-exec" => {
298            let request = match read_request_from_stdin() {
299                Ok(req) => req,
300                Err(e) => {
301                    eprintln!("Failed to parse plugin request: {e}");
302                    std::process::exit(1);
303                }
304            };
305
306            match (plugin.execute)(request) {
307                CommandResult::Plan(commands, parallel) => {
308                    output_execution_plan(commands, parallel);
309                }
310                CommandResult::FullPlan(plan) => {
311                    let response = PlanResponse { plan };
312                    println!("{}", serde_json::to_string(&response).unwrap());
313                }
314                CommandResult::Message(msg) => {
315                    if !msg.is_empty() {
316                        println!("{msg}");
317                    }
318                }
319                CommandResult::Error(e) => {
320                    eprintln!("Error: {e}");
321                    std::process::exit(1);
322                }
323                CommandResult::ShowHelp(maybe_error) => {
324                    if let Some(ref err) = maybe_error {
325                        eprintln!("error: {err}");
326                        eprintln!();
327                        // Print help to stderr when there's an error (so it's visible even if meta captures stdout)
328                        eprint_plugin_help(&plugin.info);
329                        std::process::exit(1);
330                    } else {
331                        print_plugin_help(&plugin.info);
332                        std::process::exit(0);
333                    }
334                }
335            }
336        }
337        "--help" | "-h" => {
338            print_plugin_help(&plugin.info);
339        }
340        _ => {
341            eprintln!("Unknown flag: {}. This binary is a meta plugin.", args[1]);
342            eprintln!("Use via: meta {}", plugin.info.name);
343            std::process::exit(1);
344        }
345    }
346}