whisker-plugin 0.2.5

Plugin surface for Whisker CNG — Plugin trait, IR types, JSON envelope, and the subprocess runner 3rd-party plugin binaries use.
Documentation

Whisker CNG plugin surface.

Every type and trait the plugin system depends on lives here. The crate covers three audiences with one published API:

  1. 1st-party plugins — modules inside whisker-cng that implement [Plugin] directly. The engine runs them in-process.
  2. 3rd-party plugin binaries — external crates that implement [Plugin] and call [run_as_subprocess] from their main. The engine spawns them and exchanges JSON over stdin/stdout.
  3. The engine itself (whisker-cng) — consumes [Plugin] trait objects, owns the [GenerateContext], serializes [PluginRequest] / [PluginResponse] envelopes for the subprocess path.

Keeping all three on the same crate means a whisker-cng patch bump doesn't force every 3rd-party plugin crate to rebuild — the engine depends on whisker-plugin, not the other way around, so the only churn that propagates is changes to this crate's API.

Writing a 3rd-party plugin

Implement [PluginConfig] on a Config struct (this gives the plugin its name) and [Plugin] on a unit struct that owns the apply logic, then call [run_as_subprocess] from main:

use whisker_plugin::{Operation, Plugin, PluginConfig, GenerateContext, PlistValue, Target};

#[derive(Default, serde::Serialize, serde::Deserialize)]
struct DemoConfig {
    bundle_suffix: String,
}

impl PluginConfig for DemoConfig {
    const NAME: &'static str = "example-plugin";
}

struct Demo;

impl Plugin for Demo {
    type Config = DemoConfig;
    fn apply(&self, ctx: &mut GenerateContext, cfg: &DemoConfig) -> anyhow::Result<()> {
        if let Some(ios) = ctx.ios.as_mut() {
            let key = "CFBundleSuffix".to_string();
            ios.info_plist.insert(key.clone(), PlistValue::String(cfg.bundle_suffix.clone()));
            ctx.journal.record(
                DemoConfig::NAME,
                Target::Ios,
                &format!("info_plist.{key}"),
                Operation::Set,
            );
        }
        Ok(())
    }
}

fn main() -> anyhow::Result<()> {
    whisker_plugin::run_as_subprocess(Demo)
}

What the IR covers

[IosProjectIr] and [AndroidProjectIr] each carry two layers:

  1. Core fields seeded from Config by the engine before any plugin runs — app_name, version, bundle_id / application_id, scheme, deployment_target, min_sdk, target_sdk. A plugin can read them to make decisions or override them via Operation::Override.
  2. Plugin-additive fields that plugins push into:
    • info_plist: BTreeMap<String, PlistValue> (String / Boolean / Integer / Array<String> rendered)
    • manifest.permissions: Vec<String> (dedup'd at render)
    • manifest.application_meta_data: Vec<MetaDataEntry>
    • gradle.apply_plugins: Vec<String> / gradle.dependencies: Vec<String> (raw Kotlin DSL lines emitted into app/build.gradle.kts)
    • pbxproj_ops: Vec<PbxprojOp> (AddResource / AddSource / SetBuildSetting / LinkSystemFramework materialised into the iOS xcodeproj)
    • extra_files: BTreeMap<PathBuf, FileEntry> (arbitrary files dropped into gen/, path-validated against .. traversal)

Adding a new typed field is a non-breaking change when the field is #[serde(default)]: older plugin binaries simply don't touch it, the engine sees the default. Adding a required field is a wire-format break.

Mutation journal

Plugins record every IR mutation by calling [MutationJournal::record] on ctx.journal alongside the mutation itself. The engine uses the resulting log to:

  • Attribute conflicts ("plugin A and plugin B both set info_plist.CFBundleIdentifier — that's a hard error unless one of them used override")
  • Render a human-readable summary on whisker generate --verbose
  • Diagnose 3rd-party plugins ("plugin foo mutated android.manifest.permissions[3] at sequence index 42, after plugin bar at 38")

Subprocess wire format

Stderr is reserved for human-readable diagnostics: log lines, progress messages, anything the plugin wants to surface to the user when whisker generate --verbose is in play. Stdout is strictly the [PluginResponse] JSON envelope — anything else there is a wire-format violation.