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}