-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin system - context ingestion #57
Description
Goal
Centralize core options (config loading, tag/exclude filtering) so plugins receive ready data and only implement their own functionality.
Minimal Design
1. Core Adds PluginContext
Contains:
- &Config (already loaded)
- Vec (already filtered)
- Raw args after plugin name (plugin-specific)
- Debug flag (optional)
- Mode (optional string for multi-mode plugins)
2. Unified Plugin Trait
pub trait Plugin {
fn name(&self) -> &'static str;
fn run(&self, ctx: PluginContext) -> anyhow::Result<()>;
}3. Core Flow (repos CLI)
- Parse global options: --config, --tag, --exclude-tag, --debug
- Load config, filter repositories
- Detect plugin invocation:
repos health [plugin-args...] - Build PluginContext
- Dispatch to registered plugin.
4. Registration (static table)
static PLUGINS: &[&dyn Plugin] = &[&HealthPlugin];5. Health Plugin Refactor (remove its own arg parsing)
Only interpret plugin-specific flags (e.g. mode: deps/prs, --debug optional). Fallback to default mode if absent. No config/tag parsing.
File Changes (Scaffold)
use crate::{Config, Repository};
pub struct PluginContext<'a> {
pub config: &'a Config,
pub repositories: &'a [Repository],
pub args: Vec<String>, // plugin-specific args after plugin name
pub debug: bool,
}
impl<'a> PluginContext<'a> {
pub fn new(config: &'a Config,
repositories: &'a [Repository],
args: Vec<String>,
debug: bool) -> Self {
Self { config, repositories, args, debug }
}
}pub mod context;
use anyhow::Result;
use context::PluginContext;
pub trait Plugin {
fn name(&self) -> &'static str;
fn run(&self, ctx: PluginContext) -> Result<()>;
}
// Simple lookup
pub fn find_plugin(name: &str) -> Option<&'static dyn Plugin> {
match name {
"health" => Some(&crate::plugins::health::HealthPlugin),
_ => None,
}
}use anyhow::Result;
use crate::plugin::{Plugin, context::PluginContext};
pub struct HealthPlugin;
impl Plugin for HealthPlugin {
fn name(&self) -> &'static str { "health" }
fn run(&self, ctx: PluginContext) -> Result<()> {
// Parse plugin-local args: mode (deps|prs), optional --debug override
let mut mode = "deps";
let mut debug = ctx.debug;
let mut i = 0;
while i < ctx.args.len() {
match ctx.args[i].as_str() {
"deps" | "prs" => { mode = &ctx.args[i]; i += 1; }
"--debug" | "-d" => { debug = true; i += 1; }
"--help" | "-h" => { print_health_help(); return Ok(()); }
_ => { eprintln!("health: unknown arg {}", ctx.args[i]); i += 1; }
}
}
match mode {
"deps" => run_deps(ctx.repositories, debug),
"prs" => run_prs(ctx.repositories, debug),
_ => Ok(()),
}
}
}
// Existing logic in run_deps_check / run_pr_report would be adapted to run_deps / run_prs
fn print_health_help() { /* minimal help */ }
fn run_deps(repos: &[crate::Repository], debug: bool) -> Result<()> { /* existing body */ Ok(()) }
fn run_prs(repos: &[crate::Repository], debug: bool) -> Result<()> { /* existing body */ Ok(()) }// ...existing code...
if let Some(first) = args.get(1) {
if let Some(plugin) = crate::plugin::find_plugin(first) {
// Collect plugin-specific args (after plugin name)
let plugin_args = args.iter().skip(2).cloned().collect::<Vec<_>>();
let ctx = PluginContext::new(&config, &filtered_repos, plugin_args, debug_flag);
return plugin.run(ctx);
}
}
// ...existing code...Migration Steps
- Introduce PluginContext + Plugin trait.
- Wrap existing health plugin logic (move code, preserve functions).
- Change CLI entry to detect plugin name early.
- Remove config/tag parsing from plugin main; rely on core.
Benefits
- Plugins focus purely on feature code.
- Single source of truth for config + repository filtering.
- Easier future plugins (add struct + register).
- Reduced duplication and error surface.
Minimal Changes Principle
- No change to existing filtering logic; only relocation.
- Health functionality unchanged; argument surface same.
- No broad refactors; added small, isolated modules.
Impact summary:
- Internal plugins (implementing Plugin trait) will receive PluginContext directly.
- External plugins discovered on PATH (repos-*) will continue working unchanged unless you opt into passing them preprocessed context.
- No breaking change required if you keep current exec pathway as a fallback.
Recommended compatibility approach:
- Keep existing external plugin execution
Core still resolves an external plugin binary (e.g. repos-health) and passes remaining args. External plugins keep parsing --config, --tag, --exclude-tag if they want. No immediate disruption.
- Add context injection for external plugins (optional, non-breaking)
Before spawning the external plugin, core prepares filtered repositories and makes them available via environment variables or temp file:
// ...existing code...
fn exec_external_plugin(bin: &Path, plugin_args: &[String], ctx: &PluginContext) -> anyhow::Result<()> {
// Serialize filtered repos minimally
let tmp = tempfile::NamedTempFile::new()?;
serde_json::to_writer(&tmp, &ctx.repositories)?;
let repos_file = tmp.path().to_string_lossy().to_string();
let status = std::process::Command::new(bin)
.args(plugin_args)
.env("REPOS_CONFIG_PATH", ctx.config.source_path().unwrap_or_default())
.env("REPOS_FILTERED_REPOS_FILE", &repos_file)
.env("REPOS_DEBUG", if ctx.debug { "1" } else { "0" })
.env("REPOS_PLUGIN_PROTOCOL", "1")
.status()?;
if !status.success() {
anyhow::bail!("external plugin exited with {}", status);
}
Ok(())
}
// ...existing code...External plugin authors can then skip parsing global options and just read:
// External plugin example (standalone binary main)
fn main() -> anyhow::Result<()> {
if let Ok(path) = std::env::var("REPOS_FILTERED_REPOS_FILE") {
let data = std::fs::read(path)?;
let repos: Vec<Repository> = serde_json::from_slice(&data)?;
// Only implement plugin-specific behavior
} else {
// Fallback: legacy path, parse CLI flags manually
}
Ok(())
}- Gradual migration
- Phase 1: Introduce Plugin trait + internal PluginContext (no change to external plugins).
- Phase 2: Add env injection when executing external plugins.
- Phase 3 (optional): Deprecate global flag parsing for external plugins by documenting new env protocol; keep backwards compatibility indefinitely.
- Detection logic
- If internal plugin matches name: run via trait.
- Else if binary exists on PATH: create PluginContext, pass to exec_external_plugin.
- Else: unknown command.
- No forced rewrites
External plugins that do not implement the Plugin trait:
- Continue to function exactly as today.
- Can opt-in to simplified model by checking REPOS_FILTERED_REPOS_FILE.
- If they ignore new env vars, behavior is unchanged.
- Failure isolation
If context serialization fails, fall back to legacy invocation to avoid breaking existing plugins.
Minimal core changes (high level):
- Add PluginContext struct.
- Add internal plugin registry.
- Wrap existing external plugin execution adding env injection (non-breaking).
- Do not remove existing argument pass-through.
Benefits:
- Internal plugins get zero boilerplate.
- External plugins can progressively adopt context without mandatory rewrites.
- Maintains absolute minimal change and backwards compatibility.
Next step (if approved):
Implement exec_external_plugin modification plus PluginContext addition; leave health plugin transition for a later isolated PR.