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}