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 /// Absolute path to the consuming app crate's root (the directory
259 /// holding its `Cargo.toml` / `whisker.rs`). Set by the engine
260 /// before the pipeline runs so plugins can resolve paths the user
261 /// spelled relative to their app — e.g. `whisker-asset`'s
262 /// `c.dir("assets")` resolves against this.
263 ///
264 /// `None` in unit tests / pipelines that don't run from a real
265 /// app crate. A plugin that needs it should error clearly when it
266 /// is absent rather than guessing the current working directory —
267 /// subprocess plugins don't inherit a reliable cwd.
268 #[serde(default)]
269 pub app_crate_dir: Option<PathBuf>,
270}
271
272/// Snapshot of the user-spelled `Config` values at pipeline
273/// entry. Intentionally flat + cloneable: anything plugins need
274/// from the app config has to surface here rather than pulling in
275/// the whole `Config` type, which keeps the wire format stable
276/// when `Config` grows.
277///
278/// ## Read this for "what the user said"; read the IR for
279/// "what the renderer will use"
280///
281/// `AppMeta` is **frozen at pipeline entry** — plugins don't update
282/// it. The per-target IR ([`IosProjectIr`] / [`AndroidProjectIr`])
283/// is the canonical source of truth for fields the renderer
284/// eventually consumes. If a plugin overrides `IosProjectIr.bundle_id`
285/// via `Operation::Override`, downstream plugins reading
286/// `ctx.app_meta.ios_bundle_id` will still see the *original* user
287/// value. Use `AppMeta` for attribution and diagnostics; use the
288/// IR for values that flow into the rendered project.
289#[derive(Debug, Default, Clone, Serialize, Deserialize)]
290pub struct AppMeta {
291 pub name: String,
292 pub version: String,
293 pub build_number: u32,
294 /// Some only when the iOS target is enabled in this run.
295 #[serde(default)]
296 pub ios_bundle_id: Option<String>,
297 /// Some only when the Android target is enabled in this run.
298 #[serde(default)]
299 pub android_application_id: Option<String>,
300}
301
302// ----------------------------------------------------------------------------
303// IR — iOS
304// ----------------------------------------------------------------------------
305
306/// In-memory representation of the iOS host project plugins mutate.
307///
308/// Serializes 1:1 to the JSON envelope so a 3rd-party plugin can
309/// receive it, mutate it locally, and send it back. Field ordering
310/// inside `BTreeMap`s is deterministic, so the same `(Config,
311/// plugin set)` produces a byte-identical envelope — important for
312/// the fingerprint-based skip path in `whisker-cng`.
313#[derive(Debug, Default, Clone, Serialize, Deserialize)]
314pub struct IosProjectIr {
315 // ----- Core fields ------------------------------------------------------
316 //
317 // These were string substitutions baked into the renderer's
318 // template up to RFC #164 Phase 4. Promoting them onto the IR
319 // lets a 3rd-party plugin override any of them via
320 // `Operation::Override` — e.g. a flavor plugin can append
321 // `.staging` to `bundle_id` without forking the user app's
322 // `whisker.rs`. The engine's `build_initial_context` seeds
323 // every field below from `Config`; the inputs-extraction
324 // step (`crate::ios::inputs_from`) reads them back.
325 /// `CFBundleDisplayName` / pbxproj `PRODUCT_NAME` source.
326 /// Seeded from `Config.name`.
327 #[serde(default)]
328 pub app_name: Option<String>,
329 /// `CFBundleShortVersionString` source. Seeded from
330 /// `Config.version` (default `"0.1.0"`).
331 #[serde(default)]
332 pub version: Option<String>,
333 /// `CFBundleVersion` source. Seeded from
334 /// `Config.build_number` (default `1`).
335 #[serde(default)]
336 pub build_number: Option<u32>,
337 /// pbxproj `PRODUCT_BUNDLE_IDENTIFIER` source. Seeded from
338 /// `Config.ios.bundle_id`, falling back to the top-level
339 /// `Config.bundle_id`.
340 #[serde(default)]
341 pub bundle_id: Option<String>,
342 /// Xcode scheme name. Seeded from `Config.ios.scheme`,
343 /// falling back to `Config.name`.
344 #[serde(default)]
345 pub scheme: Option<String>,
346 /// pbxproj `IPHONEOS_DEPLOYMENT_TARGET` source. Seeded from
347 /// `Config.ios.deployment_target` (default `"13.0"`).
348 #[serde(default)]
349 pub deployment_target: Option<String>,
350
351 // ----- Plugin-additive fields ----------------------------------------
352 /// `Info.plist` as a plist object tree. Renderer turns this
353 /// back into XML at the end of the pipeline.
354 #[serde(default)]
355 pub info_plist: BTreeMap<String, PlistValue>,
356
357 /// Deferred pbxproj structural ops. Full pbxproj round-tripping
358 /// is too heavyweight for the protocol; instead plugins request
359 /// the engine append resource refs / build phases / build
360 /// settings via [`PbxprojOp`], which the engine replays against
361 /// the template renderer at the end of the pipeline.
362 #[serde(default)]
363 pub pbxproj_ops: Vec<PbxprojOp>,
364
365 /// Arbitrary files to drop into `gen/ios/`. Path is relative to
366 /// the gen root. Use this for files the templates don't cover —
367 /// `.entitlements`, GoogleService-Info.plist, code-signing
368 /// helpers, etc.
369 #[serde(default)]
370 pub extra_files: BTreeMap<PathBuf, FileEntry>,
371}
372
373/// Tagged-union value for plist trees. Matches what the
374/// CoreFoundation property-list serializer accepts; the engine
375/// renders it to XML plist format.
376#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
377#[serde(tag = "type", content = "value", rename_all = "snake_case")]
378pub enum PlistValue {
379 String(String),
380 Integer(i64),
381 Real(f64),
382 Boolean(bool),
383 Array(Vec<PlistValue>),
384 Dict(BTreeMap<String, PlistValue>),
385}
386
387/// Structural mutation request against the iOS xcodeproj. The
388/// engine replays these against its pbxproj template renderer; the
389/// renderer's rules decide where (which group, which build phase)
390/// to materialize each op.
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
392#[serde(tag = "kind", rename_all = "snake_case")]
393pub enum PbxprojOp {
394 /// Add a file reference to the app target's "Resources" build
395 /// phase. `path` is relative to `gen/ios/`.
396 ///
397 /// Emitted as a flat `PBXFileReference` — Xcode copies only the
398 /// file's basename into the `.app` bundle root. Use
399 /// [`Self::AddResourceFolder`] when subdirectories must be
400 /// preserved in the bundle.
401 AddResource { path: PathBuf },
402 /// Add a **folder reference** (Xcode "blue folder") to the app
403 /// target's "Resources" build phase. `path` is relative to
404 /// `gen/ios/` and names a *directory*. Unlike [`Self::AddResource`],
405 /// Xcode copies the entire directory tree into the `.app` bundle
406 /// preserving its subdirectory structure (so
407 /// `whisker_assets/images/logo.png` lands at
408 /// `<bundle>/whisker_assets/images/logo.png`, not flattened to
409 /// `logo.png`). Backed by a `PBXFileReference` with
410 /// `lastKnownFileType = folder`.
411 AddResourceFolder { path: PathBuf },
412 /// Add a file reference compiled into the app target. `path` is
413 /// relative to `gen/ios/`.
414 AddSource { path: PathBuf },
415 /// Add a `key = value;` line into the app target's
416 /// build-settings dict for both Debug and Release.
417 SetBuildSetting { key: String, value: String },
418 /// Add a system framework (e.g. `AVFoundation.framework`) to
419 /// the app target's "Link Binary With Libraries" phase.
420 LinkSystemFramework { name: String },
421}
422
423// ----------------------------------------------------------------------------
424// IR — Android
425// ----------------------------------------------------------------------------
426
427/// In-memory representation of the Android host project plugins
428/// mutate. Same shape rationale as [`IosProjectIr`].
429#[derive(Debug, Default, Clone, Serialize, Deserialize)]
430pub struct AndroidProjectIr {
431 // ----- Core fields ------------------------------------------------------
432 //
433 // Seeded by `build_initial_context` from `Config`; mirror
434 // of the iOS core block above. See [`IosProjectIr`]'s rationale.
435 /// Activity label / `manifest.application.android:label` source.
436 /// Seeded from `Config.name`.
437 #[serde(default)]
438 pub app_name: Option<String>,
439 /// Gradle `versionName` source. Seeded from
440 /// `Config.version` (default `"0.1.0"`).
441 #[serde(default)]
442 pub version: Option<String>,
443 /// Gradle `versionCode` source. Seeded from
444 /// `Config.build_number` (default `1`).
445 #[serde(default)]
446 pub build_number: Option<u32>,
447 /// Gradle `applicationId` source. Seeded from
448 /// `Config.android.application_id`, falling back to the
449 /// top-level `Config.bundle_id`.
450 #[serde(default)]
451 pub application_id: Option<String>,
452 /// Gradle `minSdk` source. Seeded from
453 /// `Config.android.min_sdk` (default `24`).
454 #[serde(default)]
455 pub min_sdk: Option<u32>,
456 /// Gradle `targetSdk` source. Seeded from
457 /// `Config.android.target_sdk` (default `34`).
458 #[serde(default)]
459 pub target_sdk: Option<u32>,
460
461 // ----- Plugin-additive fields ----------------------------------------
462 /// Structured AndroidManifest.xml model.
463 #[serde(default)]
464 pub manifest: AndroidManifest,
465
466 /// Gradle DSL for the app module. Renderer turns this into
467 /// `app/build.gradle.kts` additions.
468 #[serde(default)]
469 pub gradle: GradleDsl,
470
471 /// Arbitrary files to drop into `gen/android/`. Same role as
472 /// [`IosProjectIr::extra_files`].
473 #[serde(default)]
474 pub extra_files: BTreeMap<PathBuf, FileEntry>,
475}
476
477#[derive(Debug, Default, Clone, Serialize, Deserialize)]
478pub struct AndroidManifest {
479 /// `<uses-permission android:name="..."/>` entries. Dedup'd by
480 /// the engine after the pipeline runs.
481 #[serde(default)]
482 pub permissions: Vec<String>,
483
484 /// `<meta-data android:name="..." android:value="..."/>` entries
485 /// inside the `<application>` block.
486 #[serde(default)]
487 pub application_meta_data: Vec<MetaDataEntry>,
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
491pub struct MetaDataEntry {
492 pub name: String,
493 pub value: String,
494}
495
496#[derive(Debug, Default, Clone, Serialize, Deserialize)]
497pub struct GradleDsl {
498 /// Plugin ids applied via the app module's `plugins { }` block,
499 /// e.g. `"com.google.gms.google-services"`.
500 #[serde(default)]
501 pub apply_plugins: Vec<String>,
502
503 /// Coordinates added to the app module's `dependencies { }`
504 /// block. Each entry is the raw DSL line, e.g.
505 /// `"implementation(\"com.google.firebase:firebase-analytics:21.5.0\")"`.
506 /// Letting plugins pass the raw line keeps `implementation` /
507 /// `api` / `kapt` differences expressible without modelling
508 /// gradle's full configuration grammar.
509 #[serde(default)]
510 pub dependencies: Vec<String>,
511}
512
513// ----------------------------------------------------------------------------
514// Shared
515// ----------------------------------------------------------------------------
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
518pub struct FileEntry {
519 /// File contents, for text files. UTF-8. Mutually exclusive with
520 /// [`Self::contents_base64`] — when the base64 field is set the
521 /// renderer writes those bytes and ignores this string.
522 pub contents: String,
523 /// Base64-encoded raw bytes, for binary files (images, fonts,
524 /// audio). `None` for ordinary text files; when `Some`, the
525 /// renderer base64-decodes it and writes the resulting bytes
526 /// verbatim (so a PNG survives the JSON wire, which can't carry
527 /// arbitrary bytes in a string). Carried as base64 rather than a
528 /// `Vec<u8>` so the [`PluginRequest`] / [`PluginResponse`] JSON
529 /// envelope stays valid UTF-8 across the subprocess boundary.
530 ///
531 /// First consumer: `whisker-asset`, which bundles arbitrary
532 /// (often binary) app assets into the generated native projects.
533 #[serde(default)]
534 pub contents_base64: Option<String>,
535 /// POSIX mode bits. `None` → engine default (`0o644`).
536 #[serde(default)]
537 pub mode: Option<u32>,
538}
539
540impl FileEntry {
541 /// A UTF-8 text file with default mode.
542 pub fn text(contents: impl Into<String>) -> Self {
543 Self {
544 contents: contents.into(),
545 contents_base64: None,
546 mode: None,
547 }
548 }
549
550 /// A binary file: `bytes` are base64-encoded into
551 /// [`Self::contents_base64`] so they survive the JSON envelope.
552 pub fn binary(bytes: &[u8]) -> Self {
553 Self {
554 contents: String::new(),
555 contents_base64: Some(base64_encode(bytes)),
556 mode: None,
557 }
558 }
559
560 /// Decode this entry to the raw bytes the renderer should write.
561 /// Returns the base64-decoded bytes when [`Self::contents_base64`]
562 /// is set, otherwise the UTF-8 [`Self::contents`] as bytes.
563 pub fn to_bytes(&self) -> anyhow::Result<Vec<u8>> {
564 match &self.contents_base64 {
565 Some(b64) => base64_decode(b64),
566 None => Ok(self.contents.clone().into_bytes()),
567 }
568 }
569}
570
571/// Standard base64 (RFC 4648, `+/`, `=` padding). Hand-rolled to
572/// avoid pulling a base64 crate into the plugin surface, which every
573/// 3rd-party plugin transitively compiles.
574fn base64_encode(input: &[u8]) -> String {
575 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
576 let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
577 for chunk in input.chunks(3) {
578 let b0 = chunk[0] as u32;
579 let b1 = *chunk.get(1).unwrap_or(&0) as u32;
580 let b2 = *chunk.get(2).unwrap_or(&0) as u32;
581 let n = (b0 << 16) | (b1 << 8) | b2;
582 out.push(ALPHABET[((n >> 18) & 0x3f) as usize] as char);
583 out.push(ALPHABET[((n >> 12) & 0x3f) as usize] as char);
584 if chunk.len() > 1 {
585 out.push(ALPHABET[((n >> 6) & 0x3f) as usize] as char);
586 } else {
587 out.push('=');
588 }
589 if chunk.len() > 2 {
590 out.push(ALPHABET[(n & 0x3f) as usize] as char);
591 } else {
592 out.push('=');
593 }
594 }
595 out
596}
597
598/// Inverse of [`base64_encode`]. Rejects invalid characters / length.
599fn base64_decode(input: &str) -> anyhow::Result<Vec<u8>> {
600 fn val(c: u8) -> anyhow::Result<u32> {
601 Ok(match c {
602 b'A'..=b'Z' => (c - b'A') as u32,
603 b'a'..=b'z' => (c - b'a' + 26) as u32,
604 b'0'..=b'9' => (c - b'0' + 52) as u32,
605 b'+' => 62,
606 b'/' => 63,
607 other => anyhow::bail!("invalid base64 character: {other:#x}"),
608 })
609 }
610 let bytes = input.as_bytes();
611 let trimmed = bytes.iter().take_while(|&&c| c != b'=').count();
612 let padded = &bytes[..trimmed];
613 if !bytes[trimmed..].iter().all(|&c| c == b'=') {
614 anyhow::bail!("base64 padding `=` must only appear at the end");
615 }
616 let mut out = Vec::with_capacity(padded.len() / 4 * 3);
617 for chunk in padded.chunks(4) {
618 if chunk.len() == 1 {
619 anyhow::bail!("invalid base64 length");
620 }
621 let mut n = 0u32;
622 for (i, &c) in chunk.iter().enumerate() {
623 n |= val(c)? << (18 - 6 * i);
624 }
625 out.push((n >> 16) as u8);
626 if chunk.len() > 2 {
627 out.push((n >> 8) as u8);
628 }
629 if chunk.len() > 3 {
630 out.push(n as u8);
631 }
632 }
633 Ok(out)
634}
635
636// ----------------------------------------------------------------------------
637// Mutation journal
638// ----------------------------------------------------------------------------
639
640#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
641#[serde(rename_all = "snake_case")]
642pub enum Target {
643 Ios,
644 Android,
645}
646
647#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
648#[serde(tag = "kind", rename_all = "snake_case")]
649pub enum Operation {
650 /// First write to a previously-unset field. Two `Set`s to the
651 /// same path from different plugins is a conflict.
652 Set,
653 /// Explicitly overwrites a prior value. Two plugins racing for
654 /// the same field can be ordered with `after()` / `before()`,
655 /// and the loser uses `Override` to acknowledge it intended to
656 /// stomp.
657 Override,
658 /// Appended one or more items to an array-shaped field
659 /// (permissions, meta-data, intent-filter list, pbxproj ops…).
660 /// `count` lets the engine surface "plugin X added 3
661 /// permissions" without recording each one individually.
662 ArrayPush { count: usize },
663}
664
665#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
666pub struct MutationRecord {
667 /// `Plugin::name()` of the plugin that requested the mutation.
668 pub plugin: String,
669 pub target: Target,
670 /// Dotted path to the field that was mutated, e.g.
671 /// `"info_plist.CFBundleIdentifier"` or
672 /// `"manifest.permissions"`.
673 pub path: String,
674 pub operation: Operation,
675 /// Monotonically-increasing per-pipeline counter. Plugins that
676 /// run earlier have smaller values.
677 pub sequence_index: u64,
678}
679
680#[derive(Debug, Default, Clone, Serialize, Deserialize)]
681pub struct MutationJournal {
682 pub records: Vec<MutationRecord>,
683 /// Next index `record()` will hand out. Stored explicitly rather
684 /// than derived from `records.len()` so the cursor stays correct
685 /// if the engine ever filters / merges entries during conflict
686 /// resolution.
687 #[serde(default)]
688 pub next_sequence_index: u64,
689}
690
691impl MutationJournal {
692 /// Allocate the next sequence index and append a record.
693 /// Plugins call this directly when they touch an IR field.
694 pub fn record(&mut self, plugin: &str, target: Target, path: &str, operation: Operation) {
695 let seq = self.next_sequence_index;
696 self.next_sequence_index = seq + 1;
697 self.records.push(MutationRecord {
698 plugin: plugin.to_string(),
699 target,
700 path: path.to_string(),
701 operation,
702 sequence_index: seq,
703 });
704 }
705}
706
707// ----------------------------------------------------------------------------
708// Subprocess envelope
709// ----------------------------------------------------------------------------
710
711/// Stdin envelope for a 3rd-party plugin subprocess. The engine
712/// writes one of these as JSON to the plugin's stdin, then reads a
713/// [`PluginResponse`] back from stdout. Single round trip per plugin
714/// per pipeline; the engine spawns one process per plugin.
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct PluginRequest {
717 /// Stable plugin name — lets the binary `match` if it ships
718 /// multiple plugins from one entry point. Most binaries serve
719 /// exactly one plugin and just assert on this field.
720 pub name: String,
721 /// Plugin's config as JSON. The subprocess deserializes it into
722 /// its `Plugin::Config` type.
723 pub config: serde_json::Value,
724 /// Current state of the IRs going into this plugin. The
725 /// subprocess gets the full context (read-only `app_meta`,
726 /// option-of IR per target, journal so far) and returns the
727 /// post-mutation version.
728 pub context: GenerateContext,
729}
730
731/// Stdout envelope. The subprocess returns the mutated context;
732/// the engine diffs the journal to confirm the subprocess didn't
733/// forge sequence indices, then merges the new context back into
734/// the running pipeline state.
735#[derive(Debug, Clone, Serialize, Deserialize)]
736pub struct PluginResponse {
737 pub context: GenerateContext,
738}
739
740// ----------------------------------------------------------------------------
741// Subprocess runner
742// ----------------------------------------------------------------------------
743
744/// Drive a [`Plugin`] as a stdin/stdout JSON subprocess.
745///
746/// Reads a [`PluginRequest`] envelope from stdin (blocking until
747/// EOF on the input pipe), runs [`Plugin::validate`] then
748/// [`Plugin::apply`], and writes a [`PluginResponse`] back to
749/// stdout. The function returns `Ok(())` on success and propagates
750/// any deserialization / validation / apply error as an
751/// `anyhow::Error` — the recommended `main` form is:
752///
753/// ```ignore
754/// fn main() -> anyhow::Result<()> {
755/// whisker_plugin::run_as_subprocess(Demo)
756/// }
757/// ```
758///
759/// `?` on the result causes the process to exit with status 1 and
760/// the error message on stderr, which is the contract the engine
761/// expects.
762pub fn run_as_subprocess<P: Plugin>(plugin: P) -> anyhow::Result<()> {
763 let mut stdin_buf = String::new();
764 std::io::stdin()
765 .read_to_string(&mut stdin_buf)
766 .map_err(|e| anyhow::anyhow!("read PluginRequest from stdin: {e}"))?;
767
768 let request: PluginRequest = serde_json::from_str(&stdin_buf)
769 .map_err(|e| anyhow::anyhow!("decode PluginRequest JSON: {e}"))?;
770
771 if request.name != plugin.name() {
772 return Err(anyhow::anyhow!(
773 "plugin name mismatch: engine asked for `{}` but this binary serves `{}`",
774 request.name,
775 plugin.name(),
776 ));
777 }
778
779 // `null` config arrives when the user didn't declare the plugin
780 // in `whisker.rs` at all — the engine's wire protocol uses Null
781 // to mean "use the Config's `Default`". This matches the
782 // in-process path's `Option::is_none` → `Default::default()`
783 // fallback, keeping the same semantics regardless of which
784 // execution mode a plugin runs in.
785 let config: P::Config = if request.config.is_null() {
786 Default::default()
787 } else {
788 serde_json::from_value(request.config)
789 .map_err(|e| anyhow::anyhow!("decode plugin config for `{}`: {e}", plugin.name()))?
790 };
791
792 plugin
793 .validate(&config)
794 .map_err(|e| anyhow::anyhow!("`{}`::validate: {e}", plugin.name()))?;
795
796 let mut ctx = request.context;
797 plugin
798 .apply(&mut ctx, &config)
799 .map_err(|e| anyhow::anyhow!("`{}`::apply: {e}", plugin.name()))?;
800
801 let response = PluginResponse { context: ctx };
802 let json = serde_json::to_string(&response)
803 .map_err(|e| anyhow::anyhow!("encode PluginResponse JSON: {e}"))?;
804
805 let mut stdout = std::io::stdout().lock();
806 stdout
807 .write_all(json.as_bytes())
808 .map_err(|e| anyhow::anyhow!("write PluginResponse to stdout: {e}"))?;
809 stdout
810 .write_all(b"\n")
811 .map_err(|e| anyhow::anyhow!("write trailing newline: {e}"))?;
812
813 Ok(())
814}
815
816// ============================================================================
817// Tests
818// ============================================================================
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823
824 #[test]
825 fn generate_context_round_trips_through_json() {
826 let mut ctx = GenerateContext {
827 app_meta: AppMeta {
828 name: "Demo".into(),
829 version: "1.0".into(),
830 build_number: 7,
831 ios_bundle_id: Some("rs.whisker.demo".into()),
832 android_application_id: Some("rs.whisker.demo".into()),
833 },
834 ios: Some(IosProjectIr::default()),
835 android: Some(AndroidProjectIr::default()),
836 journal: MutationJournal::default(),
837 app_crate_dir: None,
838 };
839 ctx.ios.as_mut().unwrap().info_plist.insert(
840 "CFBundleIdentifier".into(),
841 PlistValue::String("rs.whisker.demo".into()),
842 );
843 ctx.android
844 .as_mut()
845 .unwrap()
846 .manifest
847 .permissions
848 .push("android.permission.CAMERA".into());
849 ctx.journal.record(
850 "whisker-info-plist",
851 Target::Ios,
852 "info_plist.CFBundleIdentifier",
853 Operation::Set,
854 );
855 ctx.journal.record(
856 "whisker-permissions",
857 Target::Android,
858 "manifest.permissions",
859 Operation::ArrayPush { count: 1 },
860 );
861
862 let json = serde_json::to_string(&ctx).expect("serialize");
863 let back: GenerateContext = serde_json::from_str(&json).expect("deserialize");
864
865 assert_eq!(back.app_meta.name, "Demo");
866 assert_eq!(back.journal.records.len(), 2);
867 assert_eq!(back.journal.next_sequence_index, 2);
868 assert_eq!(
869 back.ios.unwrap().info_plist.get("CFBundleIdentifier"),
870 Some(&PlistValue::String("rs.whisker.demo".into())),
871 );
872 assert_eq!(
873 back.android.unwrap().manifest.permissions,
874 vec!["android.permission.CAMERA".to_string()],
875 );
876 }
877
878 #[test]
879 fn base64_round_trips_arbitrary_bytes() {
880 for input in [
881 &b""[..],
882 &b"f"[..],
883 &b"fo"[..],
884 &b"foo"[..],
885 &b"foob"[..],
886 &b"fooba"[..],
887 &b"foobar"[..],
888 &[0u8, 1, 2, 253, 254, 255][..],
889 ] {
890 let encoded = base64_encode(input);
891 assert!(encoded.is_ascii(), "base64 must be ASCII: {encoded}");
892 let decoded = base64_decode(&encoded).expect("decode");
893 assert_eq!(decoded, input, "round trip for {input:?}");
894 }
895 }
896
897 #[test]
898 fn base64_matches_known_vectors() {
899 // RFC 4648 test vectors.
900 assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
901 assert_eq!(base64_encode(b"fo"), "Zm8=");
902 assert_eq!(base64_decode("Zm9vYmFy").unwrap(), b"foobar");
903 assert_eq!(base64_decode("Zm8=").unwrap(), b"fo");
904 }
905
906 #[test]
907 fn base64_decode_rejects_garbage() {
908 assert!(base64_decode("not valid!").is_err());
909 }
910
911 #[test]
912 fn file_entry_binary_round_trips_through_json() {
913 let raw = &[0x89u8, 0x50, 0x4e, 0x47, 0x00, 0xff];
914 let entry = FileEntry::binary(raw);
915 let json = serde_json::to_string(&entry).unwrap();
916 let back: FileEntry = serde_json::from_str(&json).unwrap();
917 assert_eq!(back.to_bytes().unwrap(), raw);
918 }
919
920 #[test]
921 fn file_entry_text_to_bytes_is_utf8() {
922 let entry = FileEntry::text("hello");
923 assert_eq!(entry.to_bytes().unwrap(), b"hello");
924 assert!(entry.contents_base64.is_none());
925 }
926
927 #[test]
928 fn file_entry_text_default_decodes_without_base64_field() {
929 // A FileEntry serialized before `contents_base64` existed
930 // (only `contents` + `mode`) must still deserialize.
931 let json = r#"{"contents":"old text"}"#;
932 let entry: FileEntry = serde_json::from_str(json).unwrap();
933 assert_eq!(entry.to_bytes().unwrap(), b"old text");
934 }
935
936 #[test]
937 fn sequence_indices_are_monotonic() {
938 let mut j = MutationJournal::default();
939 j.record("a", Target::Ios, "x", Operation::Set);
940 j.record("b", Target::Android, "y", Operation::Set);
941 j.record("a", Target::Ios, "z", Operation::ArrayPush { count: 3 });
942 let seqs: Vec<_> = j.records.iter().map(|r| r.sequence_index).collect();
943 assert_eq!(seqs, vec![0, 1, 2]);
944 assert_eq!(j.next_sequence_index, 3);
945 }
946
947 #[test]
948 fn pbxproj_ops_round_trip() {
949 let ops = vec![
950 PbxprojOp::AddResource {
951 path: "GoogleService-Info.plist".into(),
952 },
953 PbxprojOp::LinkSystemFramework {
954 name: "AVFoundation.framework".into(),
955 },
956 PbxprojOp::SetBuildSetting {
957 key: "SWIFT_VERSION".into(),
958 value: "5".into(),
959 },
960 ];
961 let json = serde_json::to_string(&ops).unwrap();
962 let back: Vec<PbxprojOp> = serde_json::from_str(&json).unwrap();
963 assert_eq!(back, ops);
964 }
965
966 #[test]
967 fn plugin_request_envelope_round_trips() {
968 let req = PluginRequest {
969 name: "whisker-firebase".into(),
970 config: serde_json::json!({"googleServicePath": "ios/GoogleService.plist"}),
971 context: GenerateContext::default(),
972 };
973 let json = serde_json::to_string(&req).unwrap();
974 let back: PluginRequest = serde_json::from_str(&json).unwrap();
975 assert_eq!(back.name, "whisker-firebase");
976 assert_eq!(back.config["googleServicePath"], "ios/GoogleService.plist");
977 }
978
979 // Tiny plugin to exercise the trait shape — verifies the
980 // associated-type bound compiles and default methods kick in.
981 struct Null;
982
983 #[derive(Default, Serialize, Deserialize)]
984 struct NullConfig {
985 #[allow(dead_code)]
986 flag: bool,
987 }
988
989 impl PluginConfig for NullConfig {
990 const NAME: &'static str = "null";
991 }
992
993 impl Plugin for Null {
994 type Config = NullConfig;
995 fn apply(&self, _ctx: &mut GenerateContext, _config: &Self::Config) -> anyhow::Result<()> {
996 Ok(())
997 }
998 }
999
1000 #[test]
1001 fn plugin_trait_default_methods_work() {
1002 let p = Null;
1003 assert_eq!(p.name(), "null");
1004 assert!(p.after().is_empty());
1005 assert!(p.before().is_empty());
1006 let cfg = NullConfig::default();
1007 p.validate(&cfg).unwrap();
1008 let mut ctx = GenerateContext::default();
1009 p.apply(&mut ctx, &cfg).unwrap();
1010 }
1011
1012 // The subprocess runner reads stdin / writes stdout, which is
1013 // awkward to unit-test directly. Factor the core into an
1014 // in-memory shim and test that — `run_as_subprocess` is a thin
1015 // wrapper over it.
1016 fn run_with_pipes<P: Plugin>(plugin: P, input: &str) -> anyhow::Result<String> {
1017 let request: PluginRequest = serde_json::from_str(input)?;
1018 anyhow::ensure!(
1019 request.name == plugin.name(),
1020 "name mismatch: {} vs {}",
1021 request.name,
1022 plugin.name(),
1023 );
1024 let config: P::Config = serde_json::from_value(request.config)?;
1025 plugin.validate(&config)?;
1026 let mut ctx = request.context;
1027 plugin.apply(&mut ctx, &config)?;
1028 Ok(serde_json::to_string(&PluginResponse { context: ctx })?)
1029 }
1030
1031 #[derive(Default, serde::Serialize, serde::Deserialize)]
1032 struct PermissionConfig {
1033 permission: String,
1034 }
1035
1036 impl PluginConfig for PermissionConfig {
1037 const NAME: &'static str = "test-permission";
1038 }
1039
1040 struct Permission;
1041
1042 impl Plugin for Permission {
1043 type Config = PermissionConfig;
1044 fn apply(&self, ctx: &mut GenerateContext, cfg: &PermissionConfig) -> anyhow::Result<()> {
1045 let android = ctx.android.as_mut().ok_or_else(|| {
1046 anyhow::anyhow!("test-permission requires android target enabled")
1047 })?;
1048 android.manifest.permissions.push(cfg.permission.clone());
1049 ctx.journal.record(
1050 PermissionConfig::NAME,
1051 Target::Android,
1052 "manifest.permissions",
1053 Operation::ArrayPush { count: 1 },
1054 );
1055 Ok(())
1056 }
1057 }
1058
1059 #[test]
1060 fn subprocess_happy_path_round_trip() {
1061 let request = PluginRequest {
1062 name: "test-permission".into(),
1063 config: serde_json::json!({"permission": "android.permission.CAMERA"}),
1064 context: GenerateContext {
1065 android: Some(AndroidProjectIr::default()),
1066 ..Default::default()
1067 },
1068 };
1069 let input = serde_json::to_string(&request).unwrap();
1070
1071 let output = run_with_pipes(Permission, &input).unwrap();
1072 let response: PluginResponse = serde_json::from_str(&output).unwrap();
1073
1074 let android = response.context.android.expect("android should be present");
1075 assert_eq!(
1076 android.manifest.permissions,
1077 vec!["android.permission.CAMERA".to_string()],
1078 );
1079 assert_eq!(response.context.journal.records.len(), 1);
1080 assert_eq!(
1081 response.context.journal.records[0].plugin,
1082 "test-permission",
1083 );
1084 assert!(matches!(
1085 response.context.journal.records[0].operation,
1086 Operation::ArrayPush { count: 1 },
1087 ));
1088 }
1089
1090 #[test]
1091 fn subprocess_name_mismatch_is_an_error() {
1092 let request = PluginRequest {
1093 name: "some-other-plugin".into(),
1094 config: serde_json::json!({"permission": "x"}),
1095 context: GenerateContext::default(),
1096 };
1097 let input = serde_json::to_string(&request).unwrap();
1098 let err = run_with_pipes(Permission, &input).unwrap_err();
1099 assert!(err.to_string().contains("name mismatch"), "{err}");
1100 }
1101
1102 #[test]
1103 fn subprocess_apply_error_propagates() {
1104 let request = PluginRequest {
1105 name: "test-permission".into(),
1106 config: serde_json::json!({"permission": "android.permission.CAMERA"}),
1107 // No android IR — apply asserts it's present, so this
1108 // exercises the error path.
1109 context: GenerateContext::default(),
1110 };
1111 let input = serde_json::to_string(&request).unwrap();
1112 let err = run_with_pipes(Permission, &input).unwrap_err();
1113 assert!(err.to_string().contains("requires android"), "{err}");
1114 }
1115}