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    /// 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}