remove rust scaffolding — el is the implementation

This commit is contained in:
Will Anderson
2026-05-03 04:05:04 -05:00
parent c5e34ed09b
commit 9f0da76ca2
114 changed files with 0 additions and 20239 deletions
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "el-publish"
version = "0.1.0"
edition = "2021"
description = "el-ui app publishing pipeline — one command ships to every platform"
license = "MIT"
[lib]
name = "el_publish"
path = "src/lib.rs"
[dependencies]
thiserror = "1"
[dev-dependencies]
-126
View File
@@ -1,126 +0,0 @@
//! Apple App Store Connect API publisher.
//!
//! Models the App Store Connect API calls. Stub the actual HTTP — structure is
//! correct and complete. A future agent fills in the `reqwest` calls.
//!
//! API reference: https://developer.apple.com/documentation/appstoreconnectapi
use crate::{
config::{AppleConfig, PublishConfig},
metadata::StoreMetadata,
PublishError, PublishOutcome, PublishResult,
};
/// Publishes app builds to Apple App Store Connect / TestFlight.
pub struct ApplePublisher {
pub apple_config: AppleConfig,
pub publish_config: PublishConfig,
/// API key for App Store Connect (loaded from env or keychain).
#[allow(dead_code)]
api_key: Option<String>,
}
impl ApplePublisher {
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
let apple_config = publish_config.apple.clone().ok_or_else(|| {
PublishError::Config("no [publish.apple] section in manifest.el".into())
})?;
Ok(Self {
apple_config,
publish_config,
api_key: std::env::var("APP_STORE_CONNECT_API_KEY").ok(),
})
}
/// Upload a build to TestFlight.
///
/// In production, this calls the App Store Connect API:
/// POST /v1/builds
/// PUT /v1/builds/{id}/betaAppReviewDetail
/// POST /v1/betaTestersConfigurations
pub fn upload_to_testflight(
&self,
ipa_path: &str,
) -> PublishResult<String> {
// TODO: use reqwest to call App Store Connect API:
// 1. Authenticate with JWT from API key
// 2. POST /v1/builds with IPA binary
// 3. Poll build status until "READY_FOR_BETA_SUBMISSION"
// 4. Submit to TestFlight review
let _ = ipa_path;
let submission_id = format!(
"TF-{}-{}",
self.apple_config.bundle_id,
self.publish_config.build_number
);
Ok(submission_id)
}
/// Submit to App Store review.
///
/// In production:
/// POST /v1/appStoreVersionSubmissions
/// POST /v1/appStoreVersions/{id}/appStoreVersionLocalizations (for metadata)
pub fn submit_to_app_store(
&self,
build_id: &str,
metadata: &StoreMetadata,
) -> PublishResult<String> {
// TODO: actual API calls
let _ = (build_id, metadata);
let review_id = format!("AS-{}", self.publish_config.build_number);
Ok(review_id)
}
/// Set staged rollout percentage on App Store.
/// Only available after the initial release.
pub fn set_phased_release(&self, percent: u8) -> PublishResult<()> {
if percent > 100 {
return Err(PublishError::Config(format!(
"rollout percent {} exceeds 100",
percent
)));
}
// TODO: PATCH /v1/appStoreVersionPhasedReleases/{id}
let _ = percent;
Ok(())
}
/// Full publish flow: build → upload → submit.
pub fn publish(&self, ipa_path: &str, to_beta: bool) -> PublishResult<PublishOutcome> {
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
let build_id = self.upload_to_testflight(ipa_path)?;
let track = if to_beta { "testflight" } else { "app_store" };
if !to_beta {
let review_id = self.submit_to_app_store(&build_id, &metadata)?;
let _ = review_id;
}
if let Some(rollout) = &self.publish_config.rollout {
self.set_phased_release(rollout.initial_percent)?;
}
Ok(PublishOutcome::new("apple", &self.publish_config, track)
.with_submission_id(build_id))
}
/// List existing builds from App Store Connect.
pub fn list_builds(&self) -> PublishResult<Vec<BuildInfo>> {
// TODO: GET /v1/builds?filter[bundleId]=...
Ok(vec![BuildInfo {
id: format!("build-{}", self.publish_config.build_number),
version: self.publish_config.version.clone(),
status: "READY_FOR_DISTRIBUTION".into(),
}])
}
}
/// Info about a build in App Store Connect.
#[derive(Debug, Clone)]
pub struct BuildInfo {
pub id: String,
pub version: String,
pub status: String,
}
-157
View File
@@ -1,157 +0,0 @@
//! Certificate management — load, save, check expiry, generate renewal warnings.
use crate::{PublishError, PublishResult};
use std::time::{SystemTime, UNIX_EPOCH};
/// Info about a code signing certificate.
#[derive(Debug, Clone)]
pub struct CertInfo {
pub name: String,
pub team_id: String,
pub serial: String,
/// Certificate type: "Distribution", "Development", "Push", etc.
pub cert_type: String,
/// Expiry as Unix timestamp (seconds since epoch).
pub expires_at: u64,
/// Whether the certificate is currently valid.
pub is_valid: bool,
}
impl CertInfo {
pub fn new(
name: impl Into<String>,
team_id: impl Into<String>,
serial: impl Into<String>,
cert_type: impl Into<String>,
expires_at: u64,
) -> Self {
let now = unix_now();
Self {
name: name.into(),
team_id: team_id.into(),
serial: serial.into(),
cert_type: cert_type.into(),
expires_at,
is_valid: expires_at > now,
}
}
/// Days until expiry (0 if already expired).
pub fn days_until_expiry(&self) -> u64 {
let now = unix_now();
if self.expires_at <= now {
return 0;
}
(self.expires_at - now) / 86400
}
/// Whether the cert expires within `days` days.
pub fn expires_soon(&self, days: u64) -> bool {
self.days_until_expiry() <= days
}
pub fn is_expired(&self) -> bool {
unix_now() >= self.expires_at
}
}
/// Certificate store — loads, caches, and checks expiry of code signing certs.
pub struct CertStore {
certs: Vec<CertInfo>,
/// How many days before expiry to warn (default: 30).
pub warn_days: u64,
}
impl CertStore {
pub fn new() -> Self {
Self { certs: Vec::new(), warn_days: 30 }
}
pub fn with_warn_days(mut self, days: u64) -> Self {
self.warn_days = days;
self
}
/// Add a certificate to the store.
pub fn add(&mut self, cert: CertInfo) {
self.certs.push(cert);
}
/// Find a certificate by team ID and type.
pub fn find(&self, team_id: &str, cert_type: &str) -> Option<&CertInfo> {
self.certs.iter().find(|c| {
c.team_id == team_id && c.cert_type == cert_type && !c.is_expired()
})
}
/// Get all certificates expiring soon (within `warn_days`).
pub fn expiring_soon(&self) -> Vec<&CertInfo> {
self.certs
.iter()
.filter(|c| c.expires_soon(self.warn_days))
.collect()
}
/// Generate renewal warnings for expiring certificates.
pub fn renewal_warnings(&self) -> Vec<String> {
self.expiring_soon()
.iter()
.map(|c| {
if c.is_expired() {
format!(
"EXPIRED: {} ({}) — team {}. Renew immediately.",
c.name, c.cert_type, c.team_id
)
} else {
format!(
"EXPIRING SOON: {} ({}) expires in {} days — team {}. Renew before publishing.",
c.name, c.cert_type, c.days_until_expiry(), c.team_id
)
}
})
.collect()
}
/// Check that a valid distribution certificate exists for the given team.
pub fn validate_for_distribution(&self, team_id: &str) -> PublishResult<()> {
let cert = self.find(team_id, "Distribution");
match cert {
None => Err(PublishError::Certificate(format!(
"no valid Distribution certificate found for team {}. Run: el auth add-apple",
team_id
))),
Some(c) if c.expires_soon(7) => Err(PublishError::Certificate(format!(
"Distribution certificate for team {} expires in {} days. Renew now.",
team_id,
c.days_until_expiry()
))),
_ => Ok(()),
}
}
/// Load certificates from a JSON file (stub — real impl would parse
/// Apple's certificate PEM files or keychain API).
pub fn load_from_file(_path: &str) -> PublishResult<Self> {
// TODO: parse certificate PEM/P12 files, extract expiry via x509-parser
Ok(Self::new())
}
/// Save certificate metadata to a JSON cache file.
pub fn save_to_file(&self, _path: &str) -> PublishResult<()> {
// TODO: serialize cert metadata to JSON
Ok(())
}
}
impl Default for CertStore {
fn default() -> Self {
Self::new()
}
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
-144
View File
@@ -1,144 +0,0 @@
//! Publish configuration — parsed from the `[publish]` section of `manifest.el`.
/// App Store Connect / TestFlight configuration.
#[derive(Debug, Clone)]
pub struct AppleConfig {
pub account: String,
pub team_id: String,
pub bundle_id: String,
pub category: String,
}
impl AppleConfig {
pub fn new(
account: impl Into<String>,
team_id: impl Into<String>,
bundle_id: impl Into<String>,
) -> Self {
Self {
account: account.into(),
team_id: team_id.into(),
bundle_id: bundle_id.into(),
category: "productivity".into(),
}
}
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = category.into();
self
}
}
/// Google Play Developer API configuration.
#[derive(Debug, Clone)]
pub struct GoogleConfig {
pub service_account_path: String,
pub package_name: String,
/// Which track to publish to: "internal" | "alpha" | "beta" | "production"
pub track: String,
}
impl GoogleConfig {
pub fn new(
service_account_path: impl Into<String>,
package_name: impl Into<String>,
) -> Self {
Self {
service_account_path: service_account_path.into(),
package_name: package_name.into(),
track: "internal".into(),
}
}
pub fn with_track(mut self, track: impl Into<String>) -> Self {
self.track = track.into();
self
}
}
/// Staged rollout configuration.
#[derive(Debug, Clone)]
pub struct RolloutConfig {
/// Initial rollout percentage (0-100).
pub initial_percent: u8,
/// Automatically advance rollout after `advance_after_hours` hours.
pub auto_advance: bool,
pub advance_after_hours: u64,
/// Halt rollout if crash rate exceeds this fraction (0.0 - 1.0).
pub max_crash_rate: f64,
}
impl Default for RolloutConfig {
fn default() -> Self {
Self {
initial_percent: 100,
auto_advance: false,
advance_after_hours: 24,
max_crash_rate: 0.01,
}
}
}
impl RolloutConfig {
pub fn new(initial_percent: u8) -> Self {
Self { initial_percent, ..Default::default() }
}
pub fn with_auto_advance(mut self, hours: u64) -> Self {
self.auto_advance = true;
self.advance_after_hours = hours;
self
}
pub fn with_max_crash_rate(mut self, rate: f64) -> Self {
self.max_crash_rate = rate;
self
}
}
/// The complete publish configuration.
#[derive(Debug, Clone)]
pub struct PublishConfig {
pub version: String,
pub build_number: u32,
pub apple: Option<AppleConfig>,
pub google: Option<GoogleConfig>,
pub rollout: Option<RolloutConfig>,
/// Directory with store metadata (title.txt, description.txt, etc.)
pub metadata_dir: String,
pub screenshot_targets: Vec<String>,
}
impl PublishConfig {
pub fn new(version: impl Into<String>, build_number: u32) -> Self {
Self {
version: version.into(),
build_number,
apple: None,
google: None,
rollout: None,
metadata_dir: "./store".into(),
screenshot_targets: Vec::new(),
}
}
pub fn with_apple(mut self, apple: AppleConfig) -> Self {
self.apple = Some(apple);
self
}
pub fn with_google(mut self, google: GoogleConfig) -> Self {
self.google = Some(google);
self
}
pub fn with_rollout(mut self, rollout: RolloutConfig) -> Self {
self.rollout = Some(rollout);
self
}
pub fn with_screenshot_targets(mut self, targets: Vec<impl Into<String>>) -> Self {
self.screenshot_targets = targets.into_iter().map(|t| t.into()).collect();
self
}
}
-124
View File
@@ -1,124 +0,0 @@
//! Google Play Developer API publisher.
//!
//! Models the Google Play Developer API calls. Stubs the actual HTTP.
//!
//! API reference: https://developers.google.com/android-publisher
use crate::{
config::{GoogleConfig, PublishConfig},
metadata::StoreMetadata,
PublishError, PublishOutcome, PublishResult,
};
/// Publishes app bundles to Google Play Store.
pub struct GooglePublisher {
pub google_config: GoogleConfig,
pub publish_config: PublishConfig,
}
impl GooglePublisher {
pub fn new(publish_config: PublishConfig) -> PublishResult<Self> {
let google_config = publish_config.google.clone().ok_or_else(|| {
PublishError::Config("no [publish.google] section in manifest.el".into())
})?;
Ok(Self { google_config, publish_config })
}
/// Create a new edit session on Google Play.
///
/// In production: POST https://androidpublisher.googleapis.com/v3/applications/{packageName}/edits
pub fn create_edit(&self) -> PublishResult<String> {
// TODO: OAuth2 authentication via service account JSON
let edit_id = format!("edit-{}", self.publish_config.build_number);
Ok(edit_id)
}
/// Upload an AAB (Android App Bundle) to an edit session.
///
/// In production: POST .../edits/{editId}/bundles (multipart upload)
pub fn upload_bundle(
&self,
edit_id: &str,
aab_path: &str,
) -> PublishResult<u32> {
let _ = (edit_id, aab_path);
// Returns the version code of the uploaded bundle
Ok(self.publish_config.build_number)
}
/// Assign a bundle to a track.
///
/// In production: PUT .../edits/{editId}/tracks/{track}
pub fn assign_to_track(
&self,
edit_id: &str,
version_code: u32,
track: &str,
rollout_fraction: f64,
) -> PublishResult<()> {
let _ = (edit_id, version_code, track, rollout_fraction);
if !matches!(track, "internal" | "alpha" | "beta" | "production") {
return Err(PublishError::Config(format!(
"unknown Google Play track: '{}'. Use internal/alpha/beta/production",
track
)));
}
Ok(())
}
/// Upload store listing (metadata) for a locale.
///
/// In production: PATCH .../edits/{editId}/listings/{language}
pub fn upload_listing(
&self,
edit_id: &str,
locale: &str,
metadata: &StoreMetadata,
) -> PublishResult<()> {
let _ = (edit_id, locale, metadata);
Ok(())
}
/// Commit an edit (makes the changes live).
///
/// In production: POST .../edits/{editId}:commit
pub fn commit_edit(&self, edit_id: &str) -> PublishResult<String> {
// Returns the resulting version code
Ok(format!("{}-committed", edit_id))
}
/// Update rollout percentage for a track (for staged rollouts).
pub fn update_rollout(&self, track: &str, percent: u8) -> PublishResult<()> {
if percent > 100 {
return Err(PublishError::Config(format!(
"rollout percent {} exceeds 100",
percent
)));
}
let _ = (track, percent);
// TODO: PATCH .../tracks/{track} with rollout fraction
Ok(())
}
/// Full publish flow: create edit → upload bundle → assign to track → commit.
pub fn publish(&self, aab_path: &str) -> PublishResult<PublishOutcome> {
let metadata = StoreMetadata::load_from_dir(&self.publish_config.metadata_dir)?;
let edit_id = self.create_edit()?;
let version_code = self.upload_bundle(&edit_id, aab_path)?;
let track = &self.google_config.track;
let rollout_fraction = self
.publish_config
.rollout
.as_ref()
.map(|r| r.initial_percent as f64 / 100.0)
.unwrap_or(1.0);
self.assign_to_track(&edit_id, version_code, track, rollout_fraction)?;
self.upload_listing(&edit_id, "en-US", &metadata)?;
let commit_id = self.commit_edit(&edit_id)?;
Ok(PublishOutcome::new("google", &self.publish_config, track)
.with_submission_id(commit_id))
}
}
-98
View File
@@ -1,98 +0,0 @@
//! el-publish — App Store and Play Store publishing pipeline for el-ui.
//!
//! One command ships to every platform:
//! ```bash
//! el publish # all platforms
//! el publish --apple # App Store only
//! el publish --google # Play Store only
//! el publish --beta # TestFlight + Play internal track
//! ```
//!
//! Configuration in `manifest.el`:
//! ```toml
//! [publish]
//! version = "1.0.0"
//! build_number = 42
//!
//! [publish.apple]
//! account = "will@neurontechnologies.ai"
//! bundle_id = "ai.neurontechnologies.myapp"
//!
//! [publish.google]
//! package = "ai.neurontechnologies.myapp"
//! track = "internal"
//! ```
pub mod apple;
pub mod cert;
pub mod config;
pub mod google;
pub mod metadata;
pub mod rollout;
pub mod screenshot;
pub use apple::ApplePublisher;
pub use cert::{CertInfo, CertStore};
pub use config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig};
pub use google::GooglePublisher;
pub use metadata::StoreMetadata;
pub use rollout::RolloutMonitor;
pub use screenshot::{ScreenshotCapture, ScreenshotTarget};
#[cfg(test)]
mod tests;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum PublishError {
#[error("config error: {0}")]
Config(String),
#[error("build error: {0}")]
Build(String),
#[error("upload error: {0}")]
Upload(String),
#[error("certificate error: {0}")]
Certificate(String),
#[error("metadata error: {0}")]
Metadata(String),
#[error("api error: {status} {body}")]
Api { status: u16, body: String },
#[error("io error: {0}")]
Io(String),
}
pub type PublishResult<T> = Result<T, PublishError>;
/// The outcome of a publish operation.
#[derive(Debug, Clone)]
pub struct PublishOutcome {
pub platform: String,
pub version: String,
pub build_number: u32,
pub track: String,
pub rollout_percent: u8,
pub submission_id: Option<String>,
}
impl PublishOutcome {
pub fn new(
platform: impl Into<String>,
config: &PublishConfig,
track: impl Into<String>,
) -> Self {
Self {
platform: platform.into(),
version: config.version.clone(),
build_number: config.build_number,
track: track.into(),
rollout_percent: config.rollout.as_ref().map(|r| r.initial_percent).unwrap_or(100),
submission_id: None,
}
}
pub fn with_submission_id(mut self, id: impl Into<String>) -> Self {
self.submission_id = Some(id.into());
self
}
}
-122
View File
@@ -1,122 +0,0 @@
//! Store metadata — reads title.txt, description.txt, whats-new.txt, etc.
//!
//! Directory structure (mirrors Fastlane's metadata format):
//! ```text
//! store/
//! en-US/
//! title.txt
//! description.txt
//! whats-new.txt
//! keywords.txt
//! promotional-text.txt
//! de-DE/
//! title.txt
//! ...
//! ```
use crate::{PublishError, PublishResult};
use std::collections::HashMap;
use std::path::Path;
/// Metadata for a single locale.
#[derive(Debug, Clone, Default)]
pub struct LocaleMetadata {
pub title: String,
pub description: String,
pub whats_new: String,
pub keywords: Vec<String>,
pub promotional_text: String,
}
impl LocaleMetadata {
pub fn new(title: impl Into<String>, description: impl Into<String>) -> Self {
Self {
title: title.into(),
description: description.into(),
..Default::default()
}
}
pub fn with_whats_new(mut self, whats_new: impl Into<String>) -> Self {
self.whats_new = whats_new.into();
self
}
}
/// Complete store metadata across all locales.
#[derive(Debug, Clone, Default)]
pub struct StoreMetadata {
pub locales: HashMap<String, LocaleMetadata>,
}
impl StoreMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn add_locale(mut self, locale: impl Into<String>, metadata: LocaleMetadata) -> Self {
self.locales.insert(locale.into(), metadata);
self
}
/// Get metadata for a specific locale, falling back to `en-US`.
pub fn for_locale(&self, locale: &str) -> Option<&LocaleMetadata> {
self.locales.get(locale).or_else(|| self.locales.get("en-US"))
}
/// Load metadata from the directory structure.
///
/// Falls back gracefully: if the metadata directory doesn't exist,
/// returns empty metadata (don't fail the publish for missing store copy).
pub fn load_from_dir(dir: &str) -> PublishResult<Self> {
let path = Path::new(dir);
if !path.exists() {
// Warn but don't fail — metadata is optional for internal/alpha builds.
return Ok(Self::new());
}
let mut metadata = Self::new();
let read_dir = std::fs::read_dir(path).map_err(|e| {
PublishError::Metadata(format!("failed to read metadata dir {}: {}", dir, e))
})?;
for entry in read_dir.flatten() {
let locale_path = entry.path();
if !locale_path.is_dir() {
continue;
}
let locale = locale_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if locale.is_empty() {
continue;
}
let locale_meta = LocaleMetadata {
title: read_file(&locale_path, "title.txt"),
description: read_file(&locale_path, "description.txt"),
whats_new: read_file(&locale_path, "whats-new.txt"),
keywords: read_file(&locale_path, "keywords.txt")
.split(',')
.map(|k| k.trim().to_string())
.filter(|k| !k.is_empty())
.collect(),
promotional_text: read_file(&locale_path, "promotional-text.txt"),
};
metadata.locales.insert(locale, locale_meta);
}
Ok(metadata)
}
}
fn read_file(dir: &Path, filename: &str) -> String {
std::fs::read_to_string(dir.join(filename))
.unwrap_or_default()
.trim()
.to_string()
}
-131
View File
@@ -1,131 +0,0 @@
//! Rollout monitor — checks crash rate, advances or halts rollout.
use crate::{config::RolloutConfig, PublishError, PublishResult};
/// Current rollout state.
#[derive(Debug, Clone)]
pub struct RolloutState {
pub track: String,
pub current_percent: u8,
pub crash_rate: f64,
pub is_halted: bool,
pub hours_elapsed: u64,
}
impl RolloutState {
pub fn new(track: impl Into<String>, initial_percent: u8) -> Self {
Self {
track: track.into(),
current_percent: initial_percent,
crash_rate: 0.0,
is_halted: false,
hours_elapsed: 0,
}
}
}
/// Monitors a staged rollout and decides whether to advance or halt.
pub struct RolloutMonitor {
pub config: RolloutConfig,
}
impl RolloutMonitor {
pub fn new(config: RolloutConfig) -> Self {
Self { config }
}
/// Evaluate whether to advance, halt, or maintain the current rollout.
pub fn evaluate(&self, state: &RolloutState) -> RolloutDecision {
if state.is_halted {
return RolloutDecision::Halted {
reason: "rollout was previously halted".into(),
};
}
// Check crash rate
if state.crash_rate > self.config.max_crash_rate {
return RolloutDecision::Halt {
reason: format!(
"crash rate {:.2}% exceeds threshold {:.2}%",
state.crash_rate * 100.0,
self.config.max_crash_rate * 100.0
),
};
}
// Check if we should advance
if self.config.auto_advance
&& state.hours_elapsed >= self.config.advance_after_hours
&& state.current_percent < 100
{
let next_percent = advance_percent(state.current_percent);
return RolloutDecision::Advance { to_percent: next_percent };
}
RolloutDecision::Maintain
}
/// Apply a rollout decision — returns the new rollout state.
pub fn apply(
&self,
mut state: RolloutState,
decision: &RolloutDecision,
) -> PublishResult<RolloutState> {
match decision {
RolloutDecision::Advance { to_percent } => {
state.current_percent = *to_percent;
state.hours_elapsed = 0;
}
RolloutDecision::Halt { reason } => {
state.is_halted = true;
let _ = reason;
}
RolloutDecision::Halted { .. } => {
return Err(PublishError::Config(
"cannot apply decision to halted rollout".into(),
));
}
RolloutDecision::Maintain => {}
}
Ok(state)
}
/// Run a complete rollout cycle: fetch metrics, evaluate, apply.
///
/// In production: fetch crash rate from Firebase Crashlytics or App Store
/// Connect Analytics API.
pub fn tick(&self, mut state: RolloutState) -> PublishResult<(RolloutState, RolloutDecision)> {
// TODO: fetch real crash rate from analytics API
// let crash_rate = analytics_client.crash_rate(&state.track, &config.build).await?;
// state.crash_rate = crash_rate;
state.hours_elapsed += 1;
let decision = self.evaluate(&state);
let new_state = self.apply(state, &decision)?;
Ok((new_state, decision))
}
}
/// The outcome of a rollout evaluation.
#[derive(Debug, Clone)]
pub enum RolloutDecision {
/// Advance to a higher rollout percentage.
Advance { to_percent: u8 },
/// Halt the rollout due to high crash rate.
Halt { reason: String },
/// Rollout was already halted.
Halted { reason: String },
/// No change needed.
Maintain,
}
/// Determine the next rollout percentage.
/// Uses the standard staged-rollout progression: 10 → 25 → 50 → 100.
fn advance_percent(current: u8) -> u8 {
match current {
0..=9 => 10,
10..=24 => 25,
25..=49 => 50,
_ => 100,
}
}
-135
View File
@@ -1,135 +0,0 @@
//! Screenshot pipeline — captures app screenshots for each store target size.
use crate::PublishResult;
/// A screenshot target device/size specification.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScreenshotTarget {
/// Display name (e.g. "iPhone 6.7\"")
pub name: String,
/// Target identifier used in manifest.el (e.g. "iphone-6.7")
pub id: String,
pub width: u32,
pub height: u32,
pub platform: ScreenshotPlatform,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScreenshotPlatform {
Ios,
Android,
}
impl ScreenshotTarget {
/// All standard screenshot targets, matching Apple and Google requirements.
pub fn all_standard() -> Vec<Self> {
vec![
Self {
name: "iPhone 6.7\"".into(),
id: "iphone-6.7".into(),
width: 1290,
height: 2796,
platform: ScreenshotPlatform::Ios,
},
Self {
name: "iPhone 6.1\"".into(),
id: "iphone-6.1".into(),
width: 1179,
height: 2556,
platform: ScreenshotPlatform::Ios,
},
Self {
name: "iPad 13\"".into(),
id: "ipad-13".into(),
width: 2064,
height: 2752,
platform: ScreenshotPlatform::Ios,
},
Self {
name: "Android Phone".into(),
id: "android-phone".into(),
width: 1080,
height: 1920,
platform: ScreenshotPlatform::Android,
},
]
}
/// Find a target by its ID.
pub fn find(id: &str) -> Option<Self> {
Self::all_standard().into_iter().find(|t| t.id == id)
}
}
/// A captured screenshot.
#[derive(Debug, Clone)]
pub struct Screenshot {
pub target: ScreenshotTarget,
pub locale: String,
pub scenario: String,
/// Path to the PNG file.
pub path: String,
}
/// Trait for screenshot capture backends.
///
/// Implementations might use:
/// - `xcrun simctl` for iOS Simulator screenshots
/// - Android Emulator ADB screenshots
/// - Puppeteer/Playwright for web screenshots
pub trait ScreenshotCapture: Send + Sync {
/// Capture a screenshot for the given scenario and target.
fn capture(
&self,
scenario: &str,
target: &ScreenshotTarget,
locale: &str,
output_dir: &str,
) -> PublishResult<Screenshot>;
/// Capture all scenarios for all targets and locales.
fn capture_all(
&self,
scenarios: &[String],
targets: &[ScreenshotTarget],
locales: &[String],
output_dir: &str,
) -> PublishResult<Vec<Screenshot>> {
let mut screenshots = Vec::new();
for scenario in scenarios {
for target in targets {
for locale in locales {
let shot = self.capture(scenario, target, locale, output_dir)?;
screenshots.push(shot);
}
}
}
Ok(screenshots)
}
}
/// Stub screenshot capture (produces placeholder paths without actually
/// launching a simulator). A future agent fills in the real capture logic.
pub struct StubScreenshotCapture;
impl ScreenshotCapture for StubScreenshotCapture {
fn capture(
&self,
scenario: &str,
target: &ScreenshotTarget,
locale: &str,
output_dir: &str,
) -> PublishResult<Screenshot> {
let filename = format!(
"{}/{}/{}-{}.png",
output_dir, locale, target.id, scenario
);
// TODO: actually launch simulator/emulator, navigate to scenario, capture PNG
Ok(Screenshot {
target: target.clone(),
locale: locale.to_string(),
scenario: scenario.to_string(),
path: filename,
})
}
}
-264
View File
@@ -1,264 +0,0 @@
//! Tests for el-publish.
#[cfg(test)]
mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{
cert::{CertInfo, CertStore},
config::{AppleConfig, GoogleConfig, PublishConfig, RolloutConfig},
metadata::{LocaleMetadata, StoreMetadata},
rollout::{RolloutDecision, RolloutMonitor, RolloutState},
screenshot::{ScreenshotCapture, ScreenshotTarget, StubScreenshotCapture},
PublishError,
};
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn test_config() -> PublishConfig {
PublishConfig::new("1.2.0", 42)
.with_apple(AppleConfig::new(
"will@example.com",
"TEAM123",
"ai.example.myapp",
))
.with_google(GoogleConfig::new("./secrets/google.json", "ai.example.myapp"))
}
// ── Test 1: PublishConfig parses version and build number ─────────────────
#[test]
fn test_publish_config_basic() {
let cfg = test_config();
assert_eq!(cfg.version, "1.2.0");
assert_eq!(cfg.build_number, 42);
assert!(cfg.apple.is_some());
assert!(cfg.google.is_some());
}
// ── Test 2: AppleConfig has correct fields ────────────────────────────────
#[test]
fn test_apple_config() {
let cfg = AppleConfig::new("will@example.com", "TEAM123", "ai.example.myapp")
.with_category("utilities");
assert_eq!(cfg.account, "will@example.com");
assert_eq!(cfg.team_id, "TEAM123");
assert_eq!(cfg.category, "utilities");
}
// ── Test 3: GoogleConfig track defaults to internal ───────────────────────
#[test]
fn test_google_config_default_track() {
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp");
assert_eq!(cfg.track, "internal");
}
// ── Test 4: GoogleConfig with_track ──────────────────────────────────────
#[test]
fn test_google_config_track() {
let cfg = GoogleConfig::new("./google.json", "ai.example.myapp")
.with_track("production");
assert_eq!(cfg.track, "production");
}
// ── Test 5: RolloutConfig default values ─────────────────────────────────
#[test]
fn test_rollout_config_default() {
let cfg = RolloutConfig::default();
assert_eq!(cfg.initial_percent, 100);
assert!(!cfg.auto_advance);
assert_eq!(cfg.max_crash_rate, 0.01);
}
// ── Test 6: RolloutMonitor evaluates normal state as Maintain ─────────────
#[test]
fn test_rollout_maintain() {
let cfg = RolloutConfig::new(10).with_auto_advance(24);
let monitor = RolloutMonitor::new(cfg);
let state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.001, // well below threshold
is_halted: false,
hours_elapsed: 5, // less than 24h advance threshold
};
let decision = monitor.evaluate(&state);
assert!(matches!(decision, RolloutDecision::Maintain));
}
// ── Test 7: RolloutMonitor halts on high crash rate ───────────────────────
#[test]
fn test_rollout_halt_on_crash_rate() {
let cfg = RolloutConfig::new(10);
let monitor = RolloutMonitor::new(cfg);
let state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.05, // 5% > 1% threshold
is_halted: false,
hours_elapsed: 2,
};
let decision = monitor.evaluate(&state);
assert!(matches!(decision, RolloutDecision::Halt { .. }));
}
// ── Test 8: RolloutMonitor advances after threshold hours ─────────────────
#[test]
fn test_rollout_advance() {
let cfg = RolloutConfig::new(10).with_auto_advance(24);
let monitor = RolloutMonitor::new(cfg);
let state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.001,
is_halted: false,
hours_elapsed: 25, // exceeds 24h threshold
};
let decision = monitor.evaluate(&state);
assert!(matches!(decision, RolloutDecision::Advance { to_percent: 25 }));
}
// ── Test 9: RolloutMonitor advance progression 10→25→50→100 ──────────────
#[test]
fn test_rollout_advance_progression() {
let cfg = RolloutConfig::new(10).with_auto_advance(1);
let monitor = RolloutMonitor::new(cfg);
let mut state = RolloutState {
track: "production".into(),
current_percent: 10,
crash_rate: 0.001,
is_halted: false,
hours_elapsed: 5,
};
let expected = [25u8, 50, 100];
for expected_next in expected {
let decision = monitor.evaluate(&state);
if let RolloutDecision::Advance { to_percent } = decision {
state.current_percent = to_percent;
state.hours_elapsed = 5;
assert_eq!(to_percent, expected_next);
} else {
panic!("expected Advance decision, got {:?}", decision);
}
}
}
// ── Test 10: CertInfo::days_until_expiry ─────────────────────────────────
#[test]
fn test_cert_days_until_expiry() {
let future = unix_now() + 30 * 86400; // 30 days from now
let cert = CertInfo::new("My Cert", "TEAM123", "SN001", "Distribution", future);
let days = cert.days_until_expiry();
assert!(days >= 29 && days <= 30, "should be ~30 days: {}", days);
}
// ── Test 11: CertInfo::is_expired for past cert ───────────────────────────
#[test]
fn test_cert_is_expired() {
let past = unix_now() - 86400; // yesterday
let cert = CertInfo::new("Old Cert", "TEAM123", "SN002", "Distribution", past);
assert!(cert.is_expired());
assert_eq!(cert.days_until_expiry(), 0);
}
// ── Test 12: CertStore::renewal_warnings finds expiring certs ─────────────
#[test]
fn test_cert_store_renewal_warnings() {
let mut store = CertStore::new().with_warn_days(45);
let soon = unix_now() + 10 * 86400; // 10 days away
store.add(CertInfo::new("My Cert", "T1", "SN1", "Distribution", soon));
let warnings = store.renewal_warnings();
assert!(!warnings.is_empty(), "should have warnings for cert expiring in 10 days");
assert!(warnings[0].contains("EXPIRING SOON") || warnings[0].contains("EXPIRED"));
}
// ── Test 13: CertStore::validate_for_distribution fails without cert ───────
#[test]
fn test_cert_store_validate_no_cert() {
let store = CertStore::new();
let result = store.validate_for_distribution("TEAM123");
assert!(result.is_err());
match result {
Err(PublishError::Certificate(_)) => {}
_ => panic!("expected Certificate error"),
}
}
// ── Test 14: StoreMetadata::for_locale fallback to en-US ──────────────────
#[test]
fn test_store_metadata_locale_fallback() {
let meta = StoreMetadata::new()
.add_locale(
"en-US",
LocaleMetadata::new("My App", "The best app ever"),
);
// de-DE not set, should fall back to en-US
let locale_meta = meta.for_locale("de-DE").unwrap();
assert_eq!(locale_meta.title, "My App");
}
// ── Test 15: StoreMetadata::load_from_dir with missing dir is ok ──────────
#[test]
fn test_store_metadata_missing_dir() {
// Missing directory should not error — returns empty metadata
let result = StoreMetadata::load_from_dir("/nonexistent/path/that/doesnt/exist");
assert!(result.is_ok());
}
// ── Test 16: ScreenshotTarget::all_standard has expected targets ──────────
#[test]
fn test_screenshot_targets() {
let targets = ScreenshotTarget::all_standard();
assert!(targets.len() >= 4, "should have at least 4 standard targets");
let ids: Vec<&str> = targets.iter().map(|t| t.id.as_str()).collect();
assert!(ids.contains(&"iphone-6.7"));
assert!(ids.contains(&"android-phone"));
}
// ── Test 17: ScreenshotTarget::find by ID ────────────────────────────────
#[test]
fn test_screenshot_target_find() {
let target = ScreenshotTarget::find("ipad-13").unwrap();
assert_eq!(target.name, "iPad 13\"");
assert_eq!(target.width, 2064);
}
// ── Test 18: StubScreenshotCapture produces paths ─────────────────────────
#[test]
fn test_stub_screenshot_capture() {
let capture = StubScreenshotCapture;
let target = ScreenshotTarget::find("iphone-6.7").unwrap();
let shot = capture.capture("home_screen", &target, "en-US", "/tmp/screenshots").unwrap();
assert!(shot.path.contains("iphone-6.7"));
assert!(shot.path.contains("home_screen"));
assert_eq!(shot.locale, "en-US");
}
// ── Test 19: PublishConfig with rollout ───────────────────────────────────
#[test]
fn test_publish_config_with_rollout() {
let rollout = RolloutConfig::new(10)
.with_auto_advance(24)
.with_max_crash_rate(0.005);
let cfg = PublishConfig::new("1.0.0", 1).with_rollout(rollout);
let r = cfg.rollout.unwrap();
assert_eq!(r.initial_percent, 10);
assert!(r.auto_advance);
assert_eq!(r.max_crash_rate, 0.005);
}
// ── Test 20: PublishConfig screenshot_targets ─────────────────────────────
#[test]
fn test_publish_config_screenshot_targets() {
let cfg = PublishConfig::new("1.0.0", 1)
.with_screenshot_targets(vec!["iphone-6.7", "android-phone"]);
assert_eq!(cfg.screenshot_targets.len(), 2);
assert!(cfg.screenshot_targets.contains(&"iphone-6.7".to_string()));
}
}