use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::path::PathBuf;
pub trait PluginConfig: Serialize + for<'de> Deserialize<'de> + Default {
const NAME: &'static str;
}
pub trait Plugin {
type Config: PluginConfig;
fn name(&self) -> &'static str {
<Self::Config as PluginConfig>::NAME
}
fn after(&self) -> &'static [&'static str] {
&[]
}
fn before(&self) -> &'static [&'static str] {
&[]
}
fn validate(&self, _config: &Self::Config) -> anyhow::Result<()> {
Ok(())
}
fn apply(&self, ctx: &mut GenerateContext, config: &Self::Config) -> anyhow::Result<()>;
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct GenerateContext {
pub app_meta: AppMeta,
#[serde(default)]
pub ios: Option<IosProjectIr>,
#[serde(default)]
pub android: Option<AndroidProjectIr>,
#[serde(default)]
pub journal: MutationJournal,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct AppMeta {
pub name: String,
pub version: String,
pub build_number: u32,
#[serde(default)]
pub ios_bundle_id: Option<String>,
#[serde(default)]
pub android_application_id: Option<String>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct IosProjectIr {
#[serde(default)]
pub app_name: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub build_number: Option<u32>,
#[serde(default)]
pub bundle_id: Option<String>,
#[serde(default)]
pub scheme: Option<String>,
#[serde(default)]
pub deployment_target: Option<String>,
#[serde(default)]
pub info_plist: BTreeMap<String, PlistValue>,
#[serde(default)]
pub pbxproj_ops: Vec<PbxprojOp>,
#[serde(default)]
pub extra_files: BTreeMap<PathBuf, FileEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "value", rename_all = "snake_case")]
pub enum PlistValue {
String(String),
Integer(i64),
Real(f64),
Boolean(bool),
Array(Vec<PlistValue>),
Dict(BTreeMap<String, PlistValue>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PbxprojOp {
AddResource { path: PathBuf },
AddSource { path: PathBuf },
SetBuildSetting { key: String, value: String },
LinkSystemFramework { name: String },
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct AndroidProjectIr {
#[serde(default)]
pub app_name: Option<String>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub build_number: Option<u32>,
#[serde(default)]
pub application_id: Option<String>,
#[serde(default)]
pub min_sdk: Option<u32>,
#[serde(default)]
pub target_sdk: Option<u32>,
#[serde(default)]
pub manifest: AndroidManifest,
#[serde(default)]
pub gradle: GradleDsl,
#[serde(default)]
pub extra_files: BTreeMap<PathBuf, FileEntry>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct AndroidManifest {
#[serde(default)]
pub permissions: Vec<String>,
#[serde(default)]
pub application_meta_data: Vec<MetaDataEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MetaDataEntry {
pub name: String,
pub value: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct GradleDsl {
#[serde(default)]
pub apply_plugins: Vec<String>,
#[serde(default)]
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FileEntry {
pub contents: String,
#[serde(default)]
pub mode: Option<u32>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum Target {
Ios,
Android,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Operation {
Set,
Override,
ArrayPush { count: usize },
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MutationRecord {
pub plugin: String,
pub target: Target,
pub path: String,
pub operation: Operation,
pub sequence_index: u64,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct MutationJournal {
pub records: Vec<MutationRecord>,
#[serde(default)]
pub next_sequence_index: u64,
}
impl MutationJournal {
pub fn record(&mut self, plugin: &str, target: Target, path: &str, operation: Operation) {
let seq = self.next_sequence_index;
self.next_sequence_index = seq + 1;
self.records.push(MutationRecord {
plugin: plugin.to_string(),
target,
path: path.to_string(),
operation,
sequence_index: seq,
});
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginRequest {
pub name: String,
pub config: serde_json::Value,
pub context: GenerateContext,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginResponse {
pub context: GenerateContext,
}
pub fn run_as_subprocess<P: Plugin>(plugin: P) -> anyhow::Result<()> {
let mut stdin_buf = String::new();
std::io::stdin()
.read_to_string(&mut stdin_buf)
.map_err(|e| anyhow::anyhow!("read PluginRequest from stdin: {e}"))?;
let request: PluginRequest = serde_json::from_str(&stdin_buf)
.map_err(|e| anyhow::anyhow!("decode PluginRequest JSON: {e}"))?;
if request.name != plugin.name() {
return Err(anyhow::anyhow!(
"plugin name mismatch: engine asked for `{}` but this binary serves `{}`",
request.name,
plugin.name(),
));
}
let config: P::Config = if request.config.is_null() {
Default::default()
} else {
serde_json::from_value(request.config)
.map_err(|e| anyhow::anyhow!("decode plugin config for `{}`: {e}", plugin.name()))?
};
plugin
.validate(&config)
.map_err(|e| anyhow::anyhow!("`{}`::validate: {e}", plugin.name()))?;
let mut ctx = request.context;
plugin
.apply(&mut ctx, &config)
.map_err(|e| anyhow::anyhow!("`{}`::apply: {e}", plugin.name()))?;
let response = PluginResponse { context: ctx };
let json = serde_json::to_string(&response)
.map_err(|e| anyhow::anyhow!("encode PluginResponse JSON: {e}"))?;
let mut stdout = std::io::stdout().lock();
stdout
.write_all(json.as_bytes())
.map_err(|e| anyhow::anyhow!("write PluginResponse to stdout: {e}"))?;
stdout
.write_all(b"\n")
.map_err(|e| anyhow::anyhow!("write trailing newline: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_context_round_trips_through_json() {
let mut ctx = GenerateContext {
app_meta: AppMeta {
name: "Demo".into(),
version: "1.0".into(),
build_number: 7,
ios_bundle_id: Some("rs.whisker.demo".into()),
android_application_id: Some("rs.whisker.demo".into()),
},
ios: Some(IosProjectIr::default()),
android: Some(AndroidProjectIr::default()),
journal: MutationJournal::default(),
};
ctx.ios.as_mut().unwrap().info_plist.insert(
"CFBundleIdentifier".into(),
PlistValue::String("rs.whisker.demo".into()),
);
ctx.android
.as_mut()
.unwrap()
.manifest
.permissions
.push("android.permission.CAMERA".into());
ctx.journal.record(
"whisker-info-plist",
Target::Ios,
"info_plist.CFBundleIdentifier",
Operation::Set,
);
ctx.journal.record(
"whisker-permissions",
Target::Android,
"manifest.permissions",
Operation::ArrayPush { count: 1 },
);
let json = serde_json::to_string(&ctx).expect("serialize");
let back: GenerateContext = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back.app_meta.name, "Demo");
assert_eq!(back.journal.records.len(), 2);
assert_eq!(back.journal.next_sequence_index, 2);
assert_eq!(
back.ios.unwrap().info_plist.get("CFBundleIdentifier"),
Some(&PlistValue::String("rs.whisker.demo".into())),
);
assert_eq!(
back.android.unwrap().manifest.permissions,
vec!["android.permission.CAMERA".to_string()],
);
}
#[test]
fn sequence_indices_are_monotonic() {
let mut j = MutationJournal::default();
j.record("a", Target::Ios, "x", Operation::Set);
j.record("b", Target::Android, "y", Operation::Set);
j.record("a", Target::Ios, "z", Operation::ArrayPush { count: 3 });
let seqs: Vec<_> = j.records.iter().map(|r| r.sequence_index).collect();
assert_eq!(seqs, vec![0, 1, 2]);
assert_eq!(j.next_sequence_index, 3);
}
#[test]
fn pbxproj_ops_round_trip() {
let ops = vec![
PbxprojOp::AddResource {
path: "GoogleService-Info.plist".into(),
},
PbxprojOp::LinkSystemFramework {
name: "AVFoundation.framework".into(),
},
PbxprojOp::SetBuildSetting {
key: "SWIFT_VERSION".into(),
value: "5".into(),
},
];
let json = serde_json::to_string(&ops).unwrap();
let back: Vec<PbxprojOp> = serde_json::from_str(&json).unwrap();
assert_eq!(back, ops);
}
#[test]
fn plugin_request_envelope_round_trips() {
let req = PluginRequest {
name: "whisker-firebase".into(),
config: serde_json::json!({"googleServicePath": "ios/GoogleService.plist"}),
context: GenerateContext::default(),
};
let json = serde_json::to_string(&req).unwrap();
let back: PluginRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.name, "whisker-firebase");
assert_eq!(back.config["googleServicePath"], "ios/GoogleService.plist");
}
struct Null;
#[derive(Default, Serialize, Deserialize)]
struct NullConfig {
#[allow(dead_code)]
flag: bool,
}
impl PluginConfig for NullConfig {
const NAME: &'static str = "null";
}
impl Plugin for Null {
type Config = NullConfig;
fn apply(&self, _ctx: &mut GenerateContext, _config: &Self::Config) -> anyhow::Result<()> {
Ok(())
}
}
#[test]
fn plugin_trait_default_methods_work() {
let p = Null;
assert_eq!(p.name(), "null");
assert!(p.after().is_empty());
assert!(p.before().is_empty());
let cfg = NullConfig::default();
p.validate(&cfg).unwrap();
let mut ctx = GenerateContext::default();
p.apply(&mut ctx, &cfg).unwrap();
}
fn run_with_pipes<P: Plugin>(plugin: P, input: &str) -> anyhow::Result<String> {
let request: PluginRequest = serde_json::from_str(input)?;
anyhow::ensure!(
request.name == plugin.name(),
"name mismatch: {} vs {}",
request.name,
plugin.name(),
);
let config: P::Config = serde_json::from_value(request.config)?;
plugin.validate(&config)?;
let mut ctx = request.context;
plugin.apply(&mut ctx, &config)?;
Ok(serde_json::to_string(&PluginResponse { context: ctx })?)
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
struct PermissionConfig {
permission: String,
}
impl PluginConfig for PermissionConfig {
const NAME: &'static str = "test-permission";
}
struct Permission;
impl Plugin for Permission {
type Config = PermissionConfig;
fn apply(&self, ctx: &mut GenerateContext, cfg: &PermissionConfig) -> anyhow::Result<()> {
let android = ctx.android.as_mut().ok_or_else(|| {
anyhow::anyhow!("test-permission requires android target enabled")
})?;
android.manifest.permissions.push(cfg.permission.clone());
ctx.journal.record(
PermissionConfig::NAME,
Target::Android,
"manifest.permissions",
Operation::ArrayPush { count: 1 },
);
Ok(())
}
}
#[test]
fn subprocess_happy_path_round_trip() {
let request = PluginRequest {
name: "test-permission".into(),
config: serde_json::json!({"permission": "android.permission.CAMERA"}),
context: GenerateContext {
android: Some(AndroidProjectIr::default()),
..Default::default()
},
};
let input = serde_json::to_string(&request).unwrap();
let output = run_with_pipes(Permission, &input).unwrap();
let response: PluginResponse = serde_json::from_str(&output).unwrap();
let android = response.context.android.expect("android should be present");
assert_eq!(
android.manifest.permissions,
vec!["android.permission.CAMERA".to_string()],
);
assert_eq!(response.context.journal.records.len(), 1);
assert_eq!(
response.context.journal.records[0].plugin,
"test-permission",
);
assert!(matches!(
response.context.journal.records[0].operation,
Operation::ArrayPush { count: 1 },
));
}
#[test]
fn subprocess_name_mismatch_is_an_error() {
let request = PluginRequest {
name: "some-other-plugin".into(),
config: serde_json::json!({"permission": "x"}),
context: GenerateContext::default(),
};
let input = serde_json::to_string(&request).unwrap();
let err = run_with_pipes(Permission, &input).unwrap_err();
assert!(err.to_string().contains("name mismatch"), "{err}");
}
#[test]
fn subprocess_apply_error_propagates() {
let request = PluginRequest {
name: "test-permission".into(),
config: serde_json::json!({"permission": "android.permission.CAMERA"}),
context: GenerateContext::default(),
};
let input = serde_json::to_string(&request).unwrap();
let err = run_with_pipes(Permission, &input).unwrap_err();
assert!(err.to_string().contains("requires android"), "{err}");
}
}