Skip to main content

whisker_plugin/
lib.rs

1//! Whisker CNG plugin surface.
2//!
3//! Every type and trait the plugin system depends on lives here. The
4//! crate covers three audiences with one published API:
5//!
6//! 1. **1st-party plugins** — modules inside `whisker-cng` that
7//!    implement [`Plugin`] directly. The engine runs them in-process.
8//! 2. **3rd-party plugin binaries** — external crates that implement
9//!    [`Plugin`] and call [`run_as_subprocess`] from their `main`.
10//!    The engine spawns them and exchanges JSON over stdin/stdout.
11//! 3. **The engine itself** (`whisker-cng`) — consumes [`Plugin`]
12//!    trait objects, owns the [`GenerateContext`], serializes
13//!    [`PluginRequest`] / [`PluginResponse`] envelopes for the
14//!    subprocess path.
15//!
16//! Keeping all three on the same crate means a `whisker-cng` patch
17//! bump doesn't force every 3rd-party plugin crate to rebuild — the
18//! engine depends on `whisker-plugin`, not the other way around, so
19//! the only churn that propagates is changes to *this* crate's API.
20//!
21//! ## Writing a 3rd-party plugin
22//!
23//! Implement [`PluginConfig`] on a Config struct (this gives the
24//! plugin its name) and [`Plugin`] on a unit struct that owns the
25//! apply logic, then call [`run_as_subprocess`] from `main`:
26//!
27//! ```no_run
28//! use whisker_plugin::{Operation, Plugin, PluginConfig, GenerateContext, PlistValue, Target};
29//!
30//! #[derive(Default, serde::Serialize, serde::Deserialize)]
31//! struct DemoConfig {
32//!     bundle_suffix: String,
33//! }
34//!
35//! impl PluginConfig for DemoConfig {
36//!     const NAME: &'static str = "example-plugin";
37//! }
38//!
39//! struct Demo;
40//!
41//! impl Plugin for Demo {
42//!     type Config = DemoConfig;
43//!     fn apply(&self, ctx: &mut GenerateContext, cfg: &DemoConfig) -> anyhow::Result<()> {
44//!         if let Some(ios) = ctx.ios.as_mut() {
45//!             let key = "CFBundleSuffix".to_string();
46//!             ios.info_plist.insert(key.clone(), PlistValue::String(cfg.bundle_suffix.clone()));
47//!             ctx.journal.record(
48//!                 DemoConfig::NAME,
49//!                 Target::Ios,
50//!                 &format!("info_plist.{key}"),
51//!                 Operation::Set,
52//!             );
53//!         }
54//!         Ok(())
55//!     }
56//! }
57//!
58//! fn main() -> anyhow::Result<()> {
59//!     whisker_plugin::run_as_subprocess(Demo)
60//! }
61//! ```
62//!
63//! ## What the IR covers
64//!
65//! [`IosProjectIr`] and [`AndroidProjectIr`] each carry two
66//! layers:
67//!
68//! 1. **Core fields** seeded from `Config` by the engine before
69//!    any plugin runs — `app_name`, `version`, `bundle_id` /
70//!    `application_id`, `scheme`, `deployment_target`, `min_sdk`,
71//!    `target_sdk`. A plugin can read them to make decisions or
72//!    override them via `Operation::Override`.
73//! 2. **Plugin-additive fields** that plugins push into:
74//!    - `info_plist: BTreeMap<String, PlistValue>` (`String` /
75//!      `Boolean` / `Integer` / `Array<String>` rendered)
76//!    - `manifest.permissions: Vec<String>` (dedup'd at render)
77//!    - `manifest.application_meta_data: Vec<MetaDataEntry>`
78//!    - `gradle.apply_plugins: Vec<String>` /
79//!      `gradle.dependencies: Vec<String>` (raw Kotlin DSL lines
80//!      emitted into `app/build.gradle.kts`)
81//!    - `pbxproj_ops: Vec<PbxprojOp>` (`AddResource` / `AddSource` /
82//!      `SetBuildSetting` / `LinkSystemFramework` materialised
83//!      into the iOS xcodeproj)
84//!    - `extra_files: BTreeMap<PathBuf, FileEntry>` (arbitrary
85//!      files dropped into `gen/`, path-validated against `..`
86//!      traversal)
87//!
88//! Adding a new typed field is a non-breaking change when the
89//! field is `#[serde(default)]`: older plugin binaries simply
90//! don't touch it, the engine sees the default. Adding a *required*
91//! field is a wire-format break.
92//!
93//! ## Mutation journal
94//!
95//! Plugins record every IR mutation by calling
96//! [`MutationJournal::record`] on `ctx.journal` alongside the
97//! mutation itself. The engine uses the resulting log to:
98//!
99//! - Attribute conflicts ("plugin A and plugin B both `set`
100//!   `info_plist.CFBundleIdentifier` — that's a hard error unless
101//!   one of them used `override`")
102//! - Render a human-readable summary on `whisker generate --verbose`
103//! - Diagnose 3rd-party plugins ("plugin foo mutated
104//!   android.manifest.permissions[3] at sequence index 42, after
105//!   plugin bar at 38")
106//!
107//! ## Subprocess wire format
108//!
109//! Stderr is reserved for human-readable diagnostics: log lines,
110//! progress messages, anything the plugin wants to surface to the
111//! user when `whisker generate --verbose` is in play. Stdout is
112//! strictly the [`PluginResponse`] JSON envelope — anything else
113//! there is a wire-format violation.
114
115use serde::{Deserialize, Serialize};
116use std::collections::BTreeMap;
117use std::io::{Read, Write};
118use std::path::PathBuf;
119
120// ----------------------------------------------------------------------------
121// PluginConfig trait
122// ----------------------------------------------------------------------------
123
124/// Trait implemented by the typed config struct each plugin defines.
125///
126/// Carries the plugin's stable kebab-case identifier as a const so
127/// the `app.plugin::<MyPlugin>(|c| ...)` builder in `whisker-config`
128/// can resolve the storage key via `<MyPlugin as Plugin>::Config::NAME`.
129///
130/// ## Why `Serialize + DeserializeOwned`
131///
132/// Three reasons, all wire-related:
133///
134/// 1. `whisker.rs` builds the Config struct, then `whisker-cli`
135///    serializes the resulting `Config` (including this Config
136///    nested under `plugins[NAME]`) to JSON via the config probe.
137/// 2. 3rd-party plugins are subprocesses — their config arrives as
138///    JSON in the [`PluginRequest`] envelope.
139/// 3. The mutation journal records the config that produced each
140///    mutation, so we can attribute "plugin X with config Y" in
141///    error messages.
142///
143/// ## Why `Default`
144///
145/// `app.plugin::<MyPlugin>(|c| ...)` starts from `Config::default()`
146/// and lets the closure mutate it. A user who declares a plugin
147/// without touching any options should still get a working config —
148/// the call site reads as `app.plugin::<MyPlugin>(|_| {})`.
149///
150/// ## Convention for `NAME`
151///
152/// Kebab-case; prefix 1st-party plugins with `whisker-`
153/// (e.g. `whisker-info-plist`, `whisker-permissions`). The default
154/// [`Plugin::name`] implementation returns this value, so under
155/// normal use the plugin's name and its Config's `NAME` are the
156/// same string by construction.
157pub trait PluginConfig: Serialize + for<'de> Deserialize<'de> + Default {
158    const NAME: &'static str;
159}
160
161// ----------------------------------------------------------------------------
162// Plugin trait
163// ----------------------------------------------------------------------------
164
165/// What a plugin implements.
166pub trait Plugin {
167    /// Plugin-specific config. The user passes this in via
168    /// `app.plugin::<Self>(|c| c.field(...))` inside `whisker.rs` —
169    /// the `c` parameter is `&mut Self::Config`.
170    type Config: PluginConfig;
171
172    /// Stable plugin identifier, used in:
173    ///
174    /// - `after()` / `before()` cross-references
175    /// - The mutation journal
176    /// - Error messages
177    /// - The [`PluginRequest`] envelope's `name` field
178    ///
179    /// Defaults to `Self::Config::NAME` so the binding between the
180    /// plugin's Config type and the plugin's name only has to be
181    /// declared once (on the Config). The override slot is mostly
182    /// there for tests and shims that want to expose the same
183    /// Config under a different identifier; production plugins
184    /// should leave it at the default.
185    fn name(&self) -> &'static str {
186        <Self::Config as PluginConfig>::NAME
187    }
188
189    /// Plugins this one must run **after**. Used by the topological
190    /// sort in `whisker-cng::compose`. Default: empty (no ordering
191    /// constraints).
192    fn after(&self) -> &'static [&'static str] {
193        &[]
194    }
195
196    /// Plugins this one must run **before**. Same as [`Plugin::after`]
197    /// but expresses the inverse constraint — useful when you can't
198    /// (or don't want to) modify the downstream plugin's source.
199    fn before(&self) -> &'static [&'static str] {
200        &[]
201    }
202
203    /// Reject obviously-broken config before any side effects fire.
204    /// The engine runs this on every plugin before scheduling the
205    /// `apply` pass, so a validation failure aborts cleanly without
206    /// leaving a half-mutated IR behind.
207    ///
208    /// Default: accept everything.
209    fn validate(&self, _config: &Self::Config) -> anyhow::Result<()> {
210        Ok(())
211    }
212
213    /// Actually mutate the [`GenerateContext`]. This is where the
214    /// plugin reads `config`, decides what IR fields to touch, and
215    /// writes them. For each mutation the plugin also calls
216    /// [`MutationJournal::record`] on `ctx.journal` so the engine
217    /// can attribute conflicts and produce a verbose summary.
218    fn apply(&self, ctx: &mut GenerateContext, config: &Self::Config) -> anyhow::Result<()>;
219}
220
221// ----------------------------------------------------------------------------
222// GenerateContext
223// ----------------------------------------------------------------------------
224
225/// The mutable handle plugins receive in [`Plugin::apply`]. Wraps
226/// every IR the engine is currently materializing plus the running
227/// [`MutationJournal`].
228///
229/// Each target IR is `Option` because not every `whisker generate`
230/// invocation touches both platforms — the CLI passes only the IRs
231/// for targets currently enabled by the user's `--target` flag.
232/// Plugins should `if let Some(ios) = &mut ctx.ios { ... }` rather
233/// than assuming both exist.
234#[derive(Debug, Default, Clone, Serialize, Deserialize)]
235pub struct GenerateContext {
236    /// Read-only basic facts about the app, derived from
237    /// `Config`. Plugins use these as defaults — e.g. an
238    /// Info.plist plugin sets `CFBundleIdentifier` from
239    /// `app_meta.ios_bundle_id`.
240    pub app_meta: AppMeta,
241
242    /// iOS IR. `Some` when the current `whisker generate` run is
243    /// rendering `gen/ios/`.
244    #[serde(default)]
245    pub ios: Option<IosProjectIr>,
246
247    /// Android IR. `Some` when the current run is rendering
248    /// `gen/android/`.
249    #[serde(default)]
250    pub android: Option<AndroidProjectIr>,
251
252    /// Append-only attribution log. The engine inspects this after
253    /// the pipeline finishes to surface conflicts and verbose
254    /// summaries; plugins don't read it directly.
255    #[serde(default)]
256    pub journal: MutationJournal,
257}
258
259/// Snapshot of the user-spelled `Config` values at pipeline
260/// entry. Intentionally flat + cloneable: anything plugins need
261/// from the app config has to surface here rather than pulling in
262/// the whole `Config` type, which keeps the wire format stable
263/// when `Config` grows.
264///
265/// ## Read this for "what the user said"; read the IR for
266/// "what the renderer will use"
267///
268/// `AppMeta` is **frozen at pipeline entry** — plugins don't update
269/// it. The per-target IR ([`IosProjectIr`] / [`AndroidProjectIr`])
270/// is the canonical source of truth for fields the renderer
271/// eventually consumes. If a plugin overrides `IosProjectIr.bundle_id`
272/// via `Operation::Override`, downstream plugins reading
273/// `ctx.app_meta.ios_bundle_id` will still see the *original* user
274/// value. Use `AppMeta` for attribution and diagnostics; use the
275/// IR for values that flow into the rendered project.
276#[derive(Debug, Default, Clone, Serialize, Deserialize)]
277pub struct AppMeta {
278    pub name: String,
279    pub version: String,
280    pub build_number: u32,
281    /// Some only when the iOS target is enabled in this run.
282    #[serde(default)]
283    pub ios_bundle_id: Option<String>,
284    /// Some only when the Android target is enabled in this run.
285    #[serde(default)]
286    pub android_application_id: Option<String>,
287}
288
289// ----------------------------------------------------------------------------
290// IR — iOS
291// ----------------------------------------------------------------------------
292
293/// In-memory representation of the iOS host project plugins mutate.
294///
295/// Serializes 1:1 to the JSON envelope so a 3rd-party plugin can
296/// receive it, mutate it locally, and send it back. Field ordering
297/// inside `BTreeMap`s is deterministic, so the same `(Config,
298/// plugin set)` produces a byte-identical envelope — important for
299/// the fingerprint-based skip path in `whisker-cng`.
300#[derive(Debug, Default, Clone, Serialize, Deserialize)]
301pub struct IosProjectIr {
302    // ----- Core fields ------------------------------------------------------
303    //
304    // These were string substitutions baked into the renderer's
305    // template up to RFC #164 Phase 4. Promoting them onto the IR
306    // lets a 3rd-party plugin override any of them via
307    // `Operation::Override` — e.g. a flavor plugin can append
308    // `.staging` to `bundle_id` without forking the user app's
309    // `whisker.rs`. The engine's `build_initial_context` seeds
310    // every field below from `Config`; the inputs-extraction
311    // step (`crate::ios::inputs_from`) reads them back.
312    /// `CFBundleDisplayName` / pbxproj `PRODUCT_NAME` source.
313    /// Seeded from `Config.name`.
314    #[serde(default)]
315    pub app_name: Option<String>,
316    /// `CFBundleShortVersionString` source. Seeded from
317    /// `Config.version` (default `"0.1.0"`).
318    #[serde(default)]
319    pub version: Option<String>,
320    /// `CFBundleVersion` source. Seeded from
321    /// `Config.build_number` (default `1`).
322    #[serde(default)]
323    pub build_number: Option<u32>,
324    /// pbxproj `PRODUCT_BUNDLE_IDENTIFIER` source. Seeded from
325    /// `Config.ios.bundle_id`, falling back to the top-level
326    /// `Config.bundle_id`.
327    #[serde(default)]
328    pub bundle_id: Option<String>,
329    /// Xcode scheme name. Seeded from `Config.ios.scheme`,
330    /// falling back to `Config.name`.
331    #[serde(default)]
332    pub scheme: Option<String>,
333    /// pbxproj `IPHONEOS_DEPLOYMENT_TARGET` source. Seeded from
334    /// `Config.ios.deployment_target` (default `"13.0"`).
335    #[serde(default)]
336    pub deployment_target: Option<String>,
337
338    // ----- Plugin-additive fields ----------------------------------------
339    /// `Info.plist` as a plist object tree. Renderer turns this
340    /// back into XML at the end of the pipeline.
341    #[serde(default)]
342    pub info_plist: BTreeMap<String, PlistValue>,
343
344    /// Deferred pbxproj structural ops. Full pbxproj round-tripping
345    /// is too heavyweight for the protocol; instead plugins request
346    /// the engine append resource refs / build phases / build
347    /// settings via [`PbxprojOp`], which the engine replays against
348    /// the template renderer at the end of the pipeline.
349    #[serde(default)]
350    pub pbxproj_ops: Vec<PbxprojOp>,
351
352    /// Arbitrary files to drop into `gen/ios/`. Path is relative to
353    /// the gen root. Use this for files the templates don't cover —
354    /// `.entitlements`, GoogleService-Info.plist, code-signing
355    /// helpers, etc.
356    #[serde(default)]
357    pub extra_files: BTreeMap<PathBuf, FileEntry>,
358}
359
360/// Tagged-union value for plist trees. Matches what the
361/// CoreFoundation property-list serializer accepts; the engine
362/// renders it to XML plist format.
363#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
364#[serde(tag = "type", content = "value", rename_all = "snake_case")]
365pub enum PlistValue {
366    String(String),
367    Integer(i64),
368    Real(f64),
369    Boolean(bool),
370    Array(Vec<PlistValue>),
371    Dict(BTreeMap<String, PlistValue>),
372}
373
374/// Structural mutation request against the iOS xcodeproj. The
375/// engine replays these against its pbxproj template renderer; the
376/// renderer's rules decide where (which group, which build phase)
377/// to materialize each op.
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
379#[serde(tag = "kind", rename_all = "snake_case")]
380pub enum PbxprojOp {
381    /// Add a file reference to the app target's "Resources" build
382    /// phase. `path` is relative to `gen/ios/`.
383    AddResource { path: PathBuf },
384    /// Add a file reference compiled into the app target. `path` is
385    /// relative to `gen/ios/`.
386    AddSource { path: PathBuf },
387    /// Add a `key = value;` line into the app target's
388    /// build-settings dict for both Debug and Release.
389    SetBuildSetting { key: String, value: String },
390    /// Add a system framework (e.g. `AVFoundation.framework`) to
391    /// the app target's "Link Binary With Libraries" phase.
392    LinkSystemFramework { name: String },
393}
394
395// ----------------------------------------------------------------------------
396// IR — Android
397// ----------------------------------------------------------------------------
398
399/// In-memory representation of the Android host project plugins
400/// mutate. Same shape rationale as [`IosProjectIr`].
401#[derive(Debug, Default, Clone, Serialize, Deserialize)]
402pub struct AndroidProjectIr {
403    // ----- Core fields ------------------------------------------------------
404    //
405    // Seeded by `build_initial_context` from `Config`; mirror
406    // of the iOS core block above. See [`IosProjectIr`]'s rationale.
407    /// Activity label / `manifest.application.android:label` source.
408    /// Seeded from `Config.name`.
409    #[serde(default)]
410    pub app_name: Option<String>,
411    /// Gradle `versionName` source. Seeded from
412    /// `Config.version` (default `"0.1.0"`).
413    #[serde(default)]
414    pub version: Option<String>,
415    /// Gradle `versionCode` source. Seeded from
416    /// `Config.build_number` (default `1`).
417    #[serde(default)]
418    pub build_number: Option<u32>,
419    /// Gradle `applicationId` source. Seeded from
420    /// `Config.android.application_id`, falling back to the
421    /// top-level `Config.bundle_id`.
422    #[serde(default)]
423    pub application_id: Option<String>,
424    /// Gradle `minSdk` source. Seeded from
425    /// `Config.android.min_sdk` (default `24`).
426    #[serde(default)]
427    pub min_sdk: Option<u32>,
428    /// Gradle `targetSdk` source. Seeded from
429    /// `Config.android.target_sdk` (default `34`).
430    #[serde(default)]
431    pub target_sdk: Option<u32>,
432
433    // ----- Plugin-additive fields ----------------------------------------
434    /// Structured AndroidManifest.xml model.
435    #[serde(default)]
436    pub manifest: AndroidManifest,
437
438    /// Gradle DSL for the app module. Renderer turns this into
439    /// `app/build.gradle.kts` additions.
440    #[serde(default)]
441    pub gradle: GradleDsl,
442
443    /// Arbitrary files to drop into `gen/android/`. Same role as
444    /// [`IosProjectIr::extra_files`].
445    #[serde(default)]
446    pub extra_files: BTreeMap<PathBuf, FileEntry>,
447}
448
449#[derive(Debug, Default, Clone, Serialize, Deserialize)]
450pub struct AndroidManifest {
451    /// `<uses-permission android:name="..."/>` entries. Dedup'd by
452    /// the engine after the pipeline runs.
453    #[serde(default)]
454    pub permissions: Vec<String>,
455
456    /// `<meta-data android:name="..." android:value="..."/>` entries
457    /// inside the `<application>` block.
458    #[serde(default)]
459    pub application_meta_data: Vec<MetaDataEntry>,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
463pub struct MetaDataEntry {
464    pub name: String,
465    pub value: String,
466}
467
468#[derive(Debug, Default, Clone, Serialize, Deserialize)]
469pub struct GradleDsl {
470    /// Plugin ids applied via the app module's `plugins { }` block,
471    /// e.g. `"com.google.gms.google-services"`.
472    #[serde(default)]
473    pub apply_plugins: Vec<String>,
474
475    /// Coordinates added to the app module's `dependencies { }`
476    /// block. Each entry is the raw DSL line, e.g.
477    /// `"implementation(\"com.google.firebase:firebase-analytics:21.5.0\")"`.
478    /// Letting plugins pass the raw line keeps `implementation` /
479    /// `api` / `kapt` differences expressible without modelling
480    /// gradle's full configuration grammar.
481    #[serde(default)]
482    pub dependencies: Vec<String>,
483}
484
485// ----------------------------------------------------------------------------
486// Shared
487// ----------------------------------------------------------------------------
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
490pub struct FileEntry {
491    /// File contents. Always UTF-8 today; binary support will come
492    /// when a 1st-party plugin actually needs it.
493    pub contents: String,
494    /// POSIX mode bits. `None` → engine default (`0o644`).
495    #[serde(default)]
496    pub mode: Option<u32>,
497}
498
499// ----------------------------------------------------------------------------
500// Mutation journal
501// ----------------------------------------------------------------------------
502
503#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
504#[serde(rename_all = "snake_case")]
505pub enum Target {
506    Ios,
507    Android,
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
511#[serde(tag = "kind", rename_all = "snake_case")]
512pub enum Operation {
513    /// First write to a previously-unset field. Two `Set`s to the
514    /// same path from different plugins is a conflict.
515    Set,
516    /// Explicitly overwrites a prior value. Two plugins racing for
517    /// the same field can be ordered with `after()` / `before()`,
518    /// and the loser uses `Override` to acknowledge it intended to
519    /// stomp.
520    Override,
521    /// Appended one or more items to an array-shaped field
522    /// (permissions, meta-data, intent-filter list, pbxproj ops…).
523    /// `count` lets the engine surface "plugin X added 3
524    /// permissions" without recording each one individually.
525    ArrayPush { count: usize },
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
529pub struct MutationRecord {
530    /// `Plugin::name()` of the plugin that requested the mutation.
531    pub plugin: String,
532    pub target: Target,
533    /// Dotted path to the field that was mutated, e.g.
534    /// `"info_plist.CFBundleIdentifier"` or
535    /// `"manifest.permissions"`.
536    pub path: String,
537    pub operation: Operation,
538    /// Monotonically-increasing per-pipeline counter. Plugins that
539    /// run earlier have smaller values.
540    pub sequence_index: u64,
541}
542
543#[derive(Debug, Default, Clone, Serialize, Deserialize)]
544pub struct MutationJournal {
545    pub records: Vec<MutationRecord>,
546    /// Next index `record()` will hand out. Stored explicitly rather
547    /// than derived from `records.len()` so the cursor stays correct
548    /// if the engine ever filters / merges entries during conflict
549    /// resolution.
550    #[serde(default)]
551    pub next_sequence_index: u64,
552}
553
554impl MutationJournal {
555    /// Allocate the next sequence index and append a record.
556    /// Plugins call this directly when they touch an IR field.
557    pub fn record(&mut self, plugin: &str, target: Target, path: &str, operation: Operation) {
558        let seq = self.next_sequence_index;
559        self.next_sequence_index = seq + 1;
560        self.records.push(MutationRecord {
561            plugin: plugin.to_string(),
562            target,
563            path: path.to_string(),
564            operation,
565            sequence_index: seq,
566        });
567    }
568}
569
570// ----------------------------------------------------------------------------
571// Subprocess envelope
572// ----------------------------------------------------------------------------
573
574/// Stdin envelope for a 3rd-party plugin subprocess. The engine
575/// writes one of these as JSON to the plugin's stdin, then reads a
576/// [`PluginResponse`] back from stdout. Single round trip per plugin
577/// per pipeline; the engine spawns one process per plugin.
578#[derive(Debug, Clone, Serialize, Deserialize)]
579pub struct PluginRequest {
580    /// Stable plugin name — lets the binary `match` if it ships
581    /// multiple plugins from one entry point. Most binaries serve
582    /// exactly one plugin and just assert on this field.
583    pub name: String,
584    /// Plugin's config as JSON. The subprocess deserializes it into
585    /// its `Plugin::Config` type.
586    pub config: serde_json::Value,
587    /// Current state of the IRs going into this plugin. The
588    /// subprocess gets the full context (read-only `app_meta`,
589    /// option-of IR per target, journal so far) and returns the
590    /// post-mutation version.
591    pub context: GenerateContext,
592}
593
594/// Stdout envelope. The subprocess returns the mutated context;
595/// the engine diffs the journal to confirm the subprocess didn't
596/// forge sequence indices, then merges the new context back into
597/// the running pipeline state.
598#[derive(Debug, Clone, Serialize, Deserialize)]
599pub struct PluginResponse {
600    pub context: GenerateContext,
601}
602
603// ----------------------------------------------------------------------------
604// Subprocess runner
605// ----------------------------------------------------------------------------
606
607/// Drive a [`Plugin`] as a stdin/stdout JSON subprocess.
608///
609/// Reads a [`PluginRequest`] envelope from stdin (blocking until
610/// EOF on the input pipe), runs [`Plugin::validate`] then
611/// [`Plugin::apply`], and writes a [`PluginResponse`] back to
612/// stdout. The function returns `Ok(())` on success and propagates
613/// any deserialization / validation / apply error as an
614/// `anyhow::Error` — the recommended `main` form is:
615///
616/// ```ignore
617/// fn main() -> anyhow::Result<()> {
618///     whisker_plugin::run_as_subprocess(Demo)
619/// }
620/// ```
621///
622/// `?` on the result causes the process to exit with status 1 and
623/// the error message on stderr, which is the contract the engine
624/// expects.
625pub fn run_as_subprocess<P: Plugin>(plugin: P) -> anyhow::Result<()> {
626    let mut stdin_buf = String::new();
627    std::io::stdin()
628        .read_to_string(&mut stdin_buf)
629        .map_err(|e| anyhow::anyhow!("read PluginRequest from stdin: {e}"))?;
630
631    let request: PluginRequest = serde_json::from_str(&stdin_buf)
632        .map_err(|e| anyhow::anyhow!("decode PluginRequest JSON: {e}"))?;
633
634    if request.name != plugin.name() {
635        return Err(anyhow::anyhow!(
636            "plugin name mismatch: engine asked for `{}` but this binary serves `{}`",
637            request.name,
638            plugin.name(),
639        ));
640    }
641
642    // `null` config arrives when the user didn't declare the plugin
643    // in `whisker.rs` at all — the engine's wire protocol uses Null
644    // to mean "use the Config's `Default`". This matches the
645    // in-process path's `Option::is_none` → `Default::default()`
646    // fallback, keeping the same semantics regardless of which
647    // execution mode a plugin runs in.
648    let config: P::Config = if request.config.is_null() {
649        Default::default()
650    } else {
651        serde_json::from_value(request.config)
652            .map_err(|e| anyhow::anyhow!("decode plugin config for `{}`: {e}", plugin.name()))?
653    };
654
655    plugin
656        .validate(&config)
657        .map_err(|e| anyhow::anyhow!("`{}`::validate: {e}", plugin.name()))?;
658
659    let mut ctx = request.context;
660    plugin
661        .apply(&mut ctx, &config)
662        .map_err(|e| anyhow::anyhow!("`{}`::apply: {e}", plugin.name()))?;
663
664    let response = PluginResponse { context: ctx };
665    let json = serde_json::to_string(&response)
666        .map_err(|e| anyhow::anyhow!("encode PluginResponse JSON: {e}"))?;
667
668    let mut stdout = std::io::stdout().lock();
669    stdout
670        .write_all(json.as_bytes())
671        .map_err(|e| anyhow::anyhow!("write PluginResponse to stdout: {e}"))?;
672    stdout
673        .write_all(b"\n")
674        .map_err(|e| anyhow::anyhow!("write trailing newline: {e}"))?;
675
676    Ok(())
677}
678
679// ============================================================================
680// Tests
681// ============================================================================
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn generate_context_round_trips_through_json() {
689        let mut ctx = GenerateContext {
690            app_meta: AppMeta {
691                name: "Demo".into(),
692                version: "1.0".into(),
693                build_number: 7,
694                ios_bundle_id: Some("rs.whisker.demo".into()),
695                android_application_id: Some("rs.whisker.demo".into()),
696            },
697            ios: Some(IosProjectIr::default()),
698            android: Some(AndroidProjectIr::default()),
699            journal: MutationJournal::default(),
700        };
701        ctx.ios.as_mut().unwrap().info_plist.insert(
702            "CFBundleIdentifier".into(),
703            PlistValue::String("rs.whisker.demo".into()),
704        );
705        ctx.android
706            .as_mut()
707            .unwrap()
708            .manifest
709            .permissions
710            .push("android.permission.CAMERA".into());
711        ctx.journal.record(
712            "whisker-info-plist",
713            Target::Ios,
714            "info_plist.CFBundleIdentifier",
715            Operation::Set,
716        );
717        ctx.journal.record(
718            "whisker-permissions",
719            Target::Android,
720            "manifest.permissions",
721            Operation::ArrayPush { count: 1 },
722        );
723
724        let json = serde_json::to_string(&ctx).expect("serialize");
725        let back: GenerateContext = serde_json::from_str(&json).expect("deserialize");
726
727        assert_eq!(back.app_meta.name, "Demo");
728        assert_eq!(back.journal.records.len(), 2);
729        assert_eq!(back.journal.next_sequence_index, 2);
730        assert_eq!(
731            back.ios.unwrap().info_plist.get("CFBundleIdentifier"),
732            Some(&PlistValue::String("rs.whisker.demo".into())),
733        );
734        assert_eq!(
735            back.android.unwrap().manifest.permissions,
736            vec!["android.permission.CAMERA".to_string()],
737        );
738    }
739
740    #[test]
741    fn sequence_indices_are_monotonic() {
742        let mut j = MutationJournal::default();
743        j.record("a", Target::Ios, "x", Operation::Set);
744        j.record("b", Target::Android, "y", Operation::Set);
745        j.record("a", Target::Ios, "z", Operation::ArrayPush { count: 3 });
746        let seqs: Vec<_> = j.records.iter().map(|r| r.sequence_index).collect();
747        assert_eq!(seqs, vec![0, 1, 2]);
748        assert_eq!(j.next_sequence_index, 3);
749    }
750
751    #[test]
752    fn pbxproj_ops_round_trip() {
753        let ops = vec![
754            PbxprojOp::AddResource {
755                path: "GoogleService-Info.plist".into(),
756            },
757            PbxprojOp::LinkSystemFramework {
758                name: "AVFoundation.framework".into(),
759            },
760            PbxprojOp::SetBuildSetting {
761                key: "SWIFT_VERSION".into(),
762                value: "5".into(),
763            },
764        ];
765        let json = serde_json::to_string(&ops).unwrap();
766        let back: Vec<PbxprojOp> = serde_json::from_str(&json).unwrap();
767        assert_eq!(back, ops);
768    }
769
770    #[test]
771    fn plugin_request_envelope_round_trips() {
772        let req = PluginRequest {
773            name: "whisker-firebase".into(),
774            config: serde_json::json!({"googleServicePath": "ios/GoogleService.plist"}),
775            context: GenerateContext::default(),
776        };
777        let json = serde_json::to_string(&req).unwrap();
778        let back: PluginRequest = serde_json::from_str(&json).unwrap();
779        assert_eq!(back.name, "whisker-firebase");
780        assert_eq!(back.config["googleServicePath"], "ios/GoogleService.plist");
781    }
782
783    // Tiny plugin to exercise the trait shape — verifies the
784    // associated-type bound compiles and default methods kick in.
785    struct Null;
786
787    #[derive(Default, Serialize, Deserialize)]
788    struct NullConfig {
789        #[allow(dead_code)]
790        flag: bool,
791    }
792
793    impl PluginConfig for NullConfig {
794        const NAME: &'static str = "null";
795    }
796
797    impl Plugin for Null {
798        type Config = NullConfig;
799        fn apply(&self, _ctx: &mut GenerateContext, _config: &Self::Config) -> anyhow::Result<()> {
800            Ok(())
801        }
802    }
803
804    #[test]
805    fn plugin_trait_default_methods_work() {
806        let p = Null;
807        assert_eq!(p.name(), "null");
808        assert!(p.after().is_empty());
809        assert!(p.before().is_empty());
810        let cfg = NullConfig::default();
811        p.validate(&cfg).unwrap();
812        let mut ctx = GenerateContext::default();
813        p.apply(&mut ctx, &cfg).unwrap();
814    }
815
816    // The subprocess runner reads stdin / writes stdout, which is
817    // awkward to unit-test directly. Factor the core into an
818    // in-memory shim and test that — `run_as_subprocess` is a thin
819    // wrapper over it.
820    fn run_with_pipes<P: Plugin>(plugin: P, input: &str) -> anyhow::Result<String> {
821        let request: PluginRequest = serde_json::from_str(input)?;
822        anyhow::ensure!(
823            request.name == plugin.name(),
824            "name mismatch: {} vs {}",
825            request.name,
826            plugin.name(),
827        );
828        let config: P::Config = serde_json::from_value(request.config)?;
829        plugin.validate(&config)?;
830        let mut ctx = request.context;
831        plugin.apply(&mut ctx, &config)?;
832        Ok(serde_json::to_string(&PluginResponse { context: ctx })?)
833    }
834
835    #[derive(Default, serde::Serialize, serde::Deserialize)]
836    struct PermissionConfig {
837        permission: String,
838    }
839
840    impl PluginConfig for PermissionConfig {
841        const NAME: &'static str = "test-permission";
842    }
843
844    struct Permission;
845
846    impl Plugin for Permission {
847        type Config = PermissionConfig;
848        fn apply(&self, ctx: &mut GenerateContext, cfg: &PermissionConfig) -> anyhow::Result<()> {
849            let android = ctx.android.as_mut().ok_or_else(|| {
850                anyhow::anyhow!("test-permission requires android target enabled")
851            })?;
852            android.manifest.permissions.push(cfg.permission.clone());
853            ctx.journal.record(
854                PermissionConfig::NAME,
855                Target::Android,
856                "manifest.permissions",
857                Operation::ArrayPush { count: 1 },
858            );
859            Ok(())
860        }
861    }
862
863    #[test]
864    fn subprocess_happy_path_round_trip() {
865        let request = PluginRequest {
866            name: "test-permission".into(),
867            config: serde_json::json!({"permission": "android.permission.CAMERA"}),
868            context: GenerateContext {
869                android: Some(AndroidProjectIr::default()),
870                ..Default::default()
871            },
872        };
873        let input = serde_json::to_string(&request).unwrap();
874
875        let output = run_with_pipes(Permission, &input).unwrap();
876        let response: PluginResponse = serde_json::from_str(&output).unwrap();
877
878        let android = response.context.android.expect("android should be present");
879        assert_eq!(
880            android.manifest.permissions,
881            vec!["android.permission.CAMERA".to_string()],
882        );
883        assert_eq!(response.context.journal.records.len(), 1);
884        assert_eq!(
885            response.context.journal.records[0].plugin,
886            "test-permission",
887        );
888        assert!(matches!(
889            response.context.journal.records[0].operation,
890            Operation::ArrayPush { count: 1 },
891        ));
892    }
893
894    #[test]
895    fn subprocess_name_mismatch_is_an_error() {
896        let request = PluginRequest {
897            name: "some-other-plugin".into(),
898            config: serde_json::json!({"permission": "x"}),
899            context: GenerateContext::default(),
900        };
901        let input = serde_json::to_string(&request).unwrap();
902        let err = run_with_pipes(Permission, &input).unwrap_err();
903        assert!(err.to_string().contains("name mismatch"), "{err}");
904    }
905
906    #[test]
907    fn subprocess_apply_error_propagates() {
908        let request = PluginRequest {
909            name: "test-permission".into(),
910            config: serde_json::json!({"permission": "android.permission.CAMERA"}),
911            // No android IR — apply asserts it's present, so this
912            // exercises the error path.
913            context: GenerateContext::default(),
914        };
915        let input = serde_json::to_string(&request).unwrap();
916        let err = run_with_pipes(Permission, &input).unwrap_err();
917        assert!(err.to_string().contains("requires android"), "{err}");
918    }
919}