Skip to main content

appscale_core/
cloud.rs

1//! Cloud Build Service — Remote CI/CD, OTA Updates, Build Artifact Caching.
2//!
3//! Provides the Rust-side types and orchestration for:
4//! 1. **Remote CI/CD**: Define build pipelines targeting multiple platforms,
5//!    track build jobs, and collect results.
6//! 2. **OTA Updates**: Version-aware update manifests, bundle diffing metadata,
7//!    and rollback support for hot-updating JS bundles without app store releases.
8//! 3. **Build Artifact Caching**: Content-addressed cache for intermediate build
9//!    outputs (compiled Rust dylibs, bundled JS, assets) with TTL and invalidation.
10//!
11//! This module does NOT perform network I/O — it defines the data model and
12//! validation logic. Actual HTTP transport is handled by platform adapters.
13
14use crate::platform::PlatformId;
15use serde::{Serialize, Deserialize};
16use std::collections::HashMap;
17
18// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
19// Errors
20// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
21
22#[derive(Debug, thiserror::Error)]
23pub enum CloudError {
24    #[error("Build failed for target {target:?}: {reason}")]
25    BuildFailed { target: BuildTarget, reason: String },
26
27    #[error("Invalid build config: {0}")]
28    InvalidConfig(String),
29
30    #[error("Cache miss: {0}")]
31    CacheMiss(String),
32
33    #[error("OTA update rejected: {0}")]
34    OtaRejected(String),
35
36    #[error("Version conflict: current={current}, required={required}")]
37    VersionConflict { current: String, required: String },
38
39    #[error("Cloud service error: {0}")]
40    ServiceError(String),
41}
42
43pub type CloudResult<T> = Result<T, CloudError>;
44
45// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
46// 1. Remote CI/CD — Build Pipeline
47// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
48
49/// Platforms a build can target.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub enum BuildTarget {
52    Ios,
53    Android,
54    Macos,
55    Windows,
56    Web,
57    Linux,
58}
59
60impl BuildTarget {
61    pub fn to_platform_id(&self) -> Option<PlatformId> {
62        match self {
63            BuildTarget::Ios => Some(PlatformId::Ios),
64            BuildTarget::Android => Some(PlatformId::Android),
65            BuildTarget::Macos => Some(PlatformId::Macos),
66            BuildTarget::Windows => Some(PlatformId::Windows),
67            BuildTarget::Web => Some(PlatformId::Web),
68            BuildTarget::Linux => None, // PlatformId doesn't include Linux yet
69        }
70    }
71}
72
73/// Build mode (debug vs release).
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75pub enum BuildMode {
76    Debug,
77    Release,
78    Profile,
79}
80
81/// Status of a build job.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub enum BuildStatus {
84    Queued,
85    Running,
86    Succeeded,
87    Failed { reason: String },
88    Cancelled,
89}
90
91impl BuildStatus {
92    pub fn is_terminal(&self) -> bool {
93        matches!(self, BuildStatus::Succeeded | BuildStatus::Failed { .. } | BuildStatus::Cancelled)
94    }
95
96    pub fn is_success(&self) -> bool {
97        matches!(self, BuildStatus::Succeeded)
98    }
99}
100
101/// Configuration for a single build job.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct BuildJobConfig {
104    pub target: BuildTarget,
105    pub mode: BuildMode,
106    pub env_vars: HashMap<String, String>,
107    pub features: Vec<String>,
108    pub signing_profile: Option<String>,
109}
110
111/// A single build job within a pipeline.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct BuildJob {
114    pub id: String,
115    pub config: BuildJobConfig,
116    pub status: BuildStatus,
117    pub started_at: Option<f64>,
118    pub finished_at: Option<f64>,
119    pub artifact_key: Option<String>,
120    pub log_url: Option<String>,
121}
122
123impl BuildJob {
124    pub fn new(id: impl Into<String>, config: BuildJobConfig) -> Self {
125        Self {
126            id: id.into(),
127            config,
128            status: BuildStatus::Queued,
129            started_at: None,
130            finished_at: None,
131            artifact_key: None,
132            log_url: None,
133        }
134    }
135
136    pub fn duration_ms(&self) -> Option<f64> {
137        match (self.started_at, self.finished_at) {
138            (Some(start), Some(end)) => Some(end - start),
139            _ => None,
140        }
141    }
142}
143
144/// A build pipeline targeting one or more platforms.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct BuildPipeline {
147    pub id: String,
148    pub app_name: String,
149    pub app_version: String,
150    pub commit_sha: Option<String>,
151    pub jobs: Vec<BuildJob>,
152    pub created_at: f64,
153}
154
155impl BuildPipeline {
156    pub fn new(
157        id: impl Into<String>,
158        app_name: impl Into<String>,
159        app_version: impl Into<String>,
160    ) -> Self {
161        Self {
162            id: id.into(),
163            app_name: app_name.into(),
164            app_version: app_version.into(),
165            commit_sha: None,
166            jobs: Vec::new(),
167            created_at: 0.0,
168        }
169    }
170
171    /// Add a build job for a target platform.
172    pub fn add_job(&mut self, config: BuildJobConfig) -> &BuildJob {
173        let job_id = format!("{}-{:?}-{}", self.id, config.target, self.jobs.len());
174        let job = BuildJob::new(job_id, config);
175        self.jobs.push(job);
176        self.jobs.last().unwrap()
177    }
178
179    /// Check if all jobs have completed (success or failure).
180    pub fn is_complete(&self) -> bool {
181        !self.jobs.is_empty() && self.jobs.iter().all(|j| j.status.is_terminal())
182    }
183
184    /// Check if all jobs succeeded.
185    pub fn all_succeeded(&self) -> bool {
186        !self.jobs.is_empty() && self.jobs.iter().all(|j| j.status.is_success())
187    }
188
189    /// Get jobs by status.
190    pub fn jobs_with_status(&self, status_match: &BuildStatus) -> Vec<&BuildJob> {
191        self.jobs.iter().filter(|j| {
192            std::mem::discriminant(&j.status) == std::mem::discriminant(status_match)
193        }).collect()
194    }
195
196    /// Get the set of targets in this pipeline.
197    pub fn targets(&self) -> Vec<BuildTarget> {
198        self.jobs.iter().map(|j| j.config.target).collect()
199    }
200}
201
202/// Validates a build pipeline configuration before submission.
203pub fn validate_pipeline(pipeline: &BuildPipeline) -> CloudResult<()> {
204    if pipeline.app_name.is_empty() {
205        return Err(CloudError::InvalidConfig("app_name is required".into()));
206    }
207    if pipeline.app_version.is_empty() {
208        return Err(CloudError::InvalidConfig("app_version is required".into()));
209    }
210    if pipeline.jobs.is_empty() {
211        return Err(CloudError::InvalidConfig("pipeline must have at least one job".into()));
212    }
213
214    // Check for duplicate targets with same mode
215    let mut seen = std::collections::HashSet::new();
216    for job in &pipeline.jobs {
217        let key = (job.config.target, job.config.mode);
218        if !seen.insert(key) {
219            return Err(CloudError::InvalidConfig(
220                format!("Duplicate target {:?} with mode {:?}", key.0, key.1),
221            ));
222        }
223    }
224
225    Ok(())
226}
227
228// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
229// 2. OTA Updates
230// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
231
232/// Semantic version for bundles.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct BundleVersion {
235    pub major: u32,
236    pub minor: u32,
237    pub patch: u32,
238    pub build: Option<u32>,
239}
240
241impl BundleVersion {
242    pub fn new(major: u32, minor: u32, patch: u32) -> Self {
243        Self { major, minor, patch, build: None }
244    }
245
246    pub fn with_build(mut self, build: u32) -> Self {
247        self.build = Some(build);
248        self
249    }
250
251    /// Check if this version is compatible with a minimum required version.
252    /// Compatible if major matches and self >= min.
253    pub fn is_compatible_with(&self, min: &BundleVersion) -> bool {
254        if self.major != min.major {
255            return false;
256        }
257        if self.minor > min.minor {
258            return true;
259        }
260        if self.minor == min.minor {
261            return self.patch >= min.patch;
262        }
263        false
264    }
265}
266
267impl std::fmt::Display for BundleVersion {
268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
269        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
270        if let Some(build) = self.build {
271            write!(f, "+{}", build)?;
272        }
273        Ok(())
274    }
275}
276
277impl Ord for BundleVersion {
278    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
279        self.major.cmp(&other.major)
280            .then(self.minor.cmp(&other.minor))
281            .then(self.patch.cmp(&other.patch))
282            .then(self.build.unwrap_or(0).cmp(&other.build.unwrap_or(0)))
283    }
284}
285
286impl PartialOrd for BundleVersion {
287    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
288        Some(self.cmp(other))
289    }
290}
291
292/// A single OTA update entry.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct OtaUpdate {
295    pub version: BundleVersion,
296    pub min_native_version: BundleVersion,
297    pub bundle_url: String,
298    pub bundle_hash: String,
299    pub bundle_size_bytes: u64,
300    pub release_notes: String,
301    pub is_mandatory: bool,
302    pub rollout_percentage: u8,
303    pub created_at: f64,
304    pub target_platforms: Vec<BuildTarget>,
305}
306
307/// Manifest listing available OTA updates for an app.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct OtaManifest {
310    pub app_id: String,
311    pub updates: Vec<OtaUpdate>,
312}
313
314impl OtaManifest {
315    pub fn new(app_id: impl Into<String>) -> Self {
316        Self { app_id: app_id.into(), updates: Vec::new() }
317    }
318
319    /// Find the latest compatible update for the given native version and platform.
320    pub fn latest_update(
321        &self,
322        current_native: &BundleVersion,
323        platform: BuildTarget,
324    ) -> Option<&OtaUpdate> {
325        self.updates.iter()
326            .filter(|u| current_native.is_compatible_with(&u.min_native_version))
327            .filter(|u| u.target_platforms.contains(&platform))
328            .max_by(|a, b| a.version.cmp(&b.version))
329    }
330
331    /// Check if an update is available for the given version.
332    pub fn has_update_for(
333        &self,
334        current_bundle: &BundleVersion,
335        current_native: &BundleVersion,
336        platform: BuildTarget,
337    ) -> bool {
338        self.latest_update(current_native, platform)
339            .map(|u| u.version > *current_bundle)
340            .unwrap_or(false)
341    }
342}
343
344/// Decision about whether to apply an OTA update.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub enum OtaDecision {
347    /// No update available.
348    NoUpdate,
349    /// Update available but not mandatory — user choice.
350    Optional { version: BundleVersion },
351    /// Mandatory update — must apply before continuing.
352    Mandatory { version: BundleVersion },
353    /// Update available but native version is too old — needs app store update.
354    NativeUpdateRequired { min_native: BundleVersion },
355}
356
357/// Evaluate whether an OTA update should be applied.
358pub fn evaluate_ota(
359    manifest: &OtaManifest,
360    current_bundle: &BundleVersion,
361    current_native: &BundleVersion,
362    platform: BuildTarget,
363) -> OtaDecision {
364    // Check if any update requires a newer native version
365    let newest = manifest.updates.iter()
366        .filter(|u| u.target_platforms.contains(&platform))
367        .max_by(|a, b| a.version.cmp(&b.version));
368
369    let Some(newest) = newest else {
370        return OtaDecision::NoUpdate;
371    };
372
373    // If already on latest or newer, no update
374    if current_bundle >= &newest.version {
375        return OtaDecision::NoUpdate;
376    }
377
378    // If native version isn't compatible with the latest
379    if !current_native.is_compatible_with(&newest.min_native_version) {
380        return OtaDecision::NativeUpdateRequired {
381            min_native: newest.min_native_version.clone(),
382        };
383    }
384
385    // Find the best compatible update
386    match manifest.latest_update(current_native, platform) {
387        Some(update) if update.version > *current_bundle => {
388            if update.is_mandatory {
389                OtaDecision::Mandatory { version: update.version.clone() }
390            } else {
391                OtaDecision::Optional { version: update.version.clone() }
392            }
393        }
394        _ => OtaDecision::NoUpdate,
395    }
396}
397
398// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
399// 3. Build Artifact Caching
400// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
401
402/// A content-addressed cache key.
403#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
404pub struct CacheKey {
405    /// Hash of the inputs (source files, deps, config).
406    pub content_hash: String,
407    /// Target platform.
408    pub target: BuildTarget,
409    /// Build mode.
410    pub mode: BuildMode,
411}
412
413impl CacheKey {
414    pub fn new(
415        content_hash: impl Into<String>,
416        target: BuildTarget,
417        mode: BuildMode,
418    ) -> Self {
419        Self { content_hash: content_hash.into(), target, mode }
420    }
421}
422
423impl std::fmt::Display for CacheKey {
424    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
425        write!(f, "{:?}-{:?}-{}", self.target, self.mode, self.content_hash)
426    }
427}
428
429/// A cached build artifact entry.
430#[derive(Debug, Clone, Serialize, Deserialize)]
431pub struct CacheEntry {
432    pub key: CacheKey,
433    pub artifact_path: String,
434    pub size_bytes: u64,
435    pub created_at: f64,
436    pub last_accessed: f64,
437    pub ttl_hours: u32,
438    pub metadata: HashMap<String, String>,
439}
440
441impl CacheEntry {
442    /// Check if this entry has expired (based on creation time and TTL).
443    pub fn is_expired(&self, now: f64) -> bool {
444        let ttl_ms = self.ttl_hours as f64 * 3600.0 * 1000.0;
445        now - self.created_at > ttl_ms
446    }
447}
448
449/// In-memory artifact cache with LRU eviction.
450pub struct ArtifactCache {
451    entries: HashMap<CacheKey, CacheEntry>,
452    max_size_bytes: u64,
453    current_size_bytes: u64,
454}
455
456impl ArtifactCache {
457    pub fn new(max_size_bytes: u64) -> Self {
458        Self {
459            entries: HashMap::new(),
460            max_size_bytes,
461            current_size_bytes: 0,
462        }
463    }
464
465    /// Look up an artifact by cache key.
466    pub fn get(&mut self, key: &CacheKey, now: f64) -> Option<&CacheEntry> {
467        // Remove if expired
468        if let Some(entry) = self.entries.get(key) {
469            if entry.is_expired(now) {
470                let size = entry.size_bytes;
471                self.entries.remove(key);
472                self.current_size_bytes = self.current_size_bytes.saturating_sub(size);
473                return None;
474            }
475        }
476        // Update last_accessed
477        if let Some(entry) = self.entries.get_mut(key) {
478            entry.last_accessed = now;
479        }
480        self.entries.get(key)
481    }
482
483    /// Insert an artifact into the cache. Evicts oldest entries if needed.
484    pub fn insert(&mut self, entry: CacheEntry) {
485        // Evict if necessary
486        while self.current_size_bytes + entry.size_bytes > self.max_size_bytes
487            && !self.entries.is_empty()
488        {
489            self.evict_oldest();
490        }
491
492        // Don't cache if single entry exceeds max
493        if entry.size_bytes > self.max_size_bytes {
494            return;
495        }
496
497        self.current_size_bytes += entry.size_bytes;
498        self.entries.insert(entry.key.clone(), entry);
499    }
500
501    /// Remove an entry by key.
502    pub fn remove(&mut self, key: &CacheKey) -> Option<CacheEntry> {
503        let entry = self.entries.remove(key)?;
504        self.current_size_bytes = self.current_size_bytes.saturating_sub(entry.size_bytes);
505        Some(entry)
506    }
507
508    /// Remove all expired entries.
509    pub fn purge_expired(&mut self, now: f64) -> usize {
510        let expired_keys: Vec<CacheKey> = self.entries.iter()
511            .filter(|(_, e)| e.is_expired(now))
512            .map(|(k, _)| k.clone())
513            .collect();
514        let count = expired_keys.len();
515        for key in expired_keys {
516            self.remove(&key);
517        }
518        count
519    }
520
521    /// Evict the least-recently-accessed entry.
522    fn evict_oldest(&mut self) {
523        let oldest_key = self.entries.iter()
524            .min_by(|a, b| a.1.last_accessed.partial_cmp(&b.1.last_accessed).unwrap_or(std::cmp::Ordering::Equal))
525            .map(|(k, _)| k.clone());
526        if let Some(key) = oldest_key {
527            self.remove(&key);
528        }
529    }
530
531    /// Number of cached entries.
532    pub fn len(&self) -> usize {
533        self.entries.len()
534    }
535
536    /// Whether the cache is empty.
537    pub fn is_empty(&self) -> bool {
538        self.entries.is_empty()
539    }
540
541    /// Current cache size in bytes.
542    pub fn size_bytes(&self) -> u64 {
543        self.current_size_bytes
544    }
545
546    /// Cache utilization as a fraction (0.0 – 1.0).
547    pub fn utilization(&self) -> f64 {
548        if self.max_size_bytes == 0 {
549            return 0.0;
550        }
551        self.current_size_bytes as f64 / self.max_size_bytes as f64
552    }
553}
554
555// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
556// Tests
557// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    // ── Build Pipeline Tests ──
564
565    #[test]
566    fn test_pipeline_creation_and_validation() {
567        let mut pipeline = BuildPipeline::new("p1", "MyApp", "1.0.0");
568        pipeline.add_job(BuildJobConfig {
569            target: BuildTarget::Ios,
570            mode: BuildMode::Release,
571            env_vars: HashMap::new(),
572            features: vec!["push".into()],
573            signing_profile: Some("dist".into()),
574        });
575        pipeline.add_job(BuildJobConfig {
576            target: BuildTarget::Android,
577            mode: BuildMode::Release,
578            env_vars: HashMap::new(),
579            features: vec![],
580            signing_profile: None,
581        });
582
583        assert_eq!(pipeline.jobs.len(), 2);
584        assert!(!pipeline.is_complete());
585        assert!(validate_pipeline(&pipeline).is_ok());
586    }
587
588    #[test]
589    fn test_pipeline_validation_empty_name() {
590        let pipeline = BuildPipeline::new("p1", "", "1.0.0");
591        assert!(validate_pipeline(&pipeline).is_err());
592    }
593
594    #[test]
595    fn test_pipeline_validation_no_jobs() {
596        let pipeline = BuildPipeline::new("p1", "App", "1.0.0");
597        assert!(validate_pipeline(&pipeline).is_err());
598    }
599
600    #[test]
601    fn test_pipeline_validation_duplicate_targets() {
602        let mut pipeline = BuildPipeline::new("p1", "App", "1.0.0");
603        pipeline.add_job(BuildJobConfig {
604            target: BuildTarget::Ios,
605            mode: BuildMode::Release,
606            env_vars: HashMap::new(),
607            features: vec![],
608            signing_profile: None,
609        });
610        pipeline.add_job(BuildJobConfig {
611            target: BuildTarget::Ios,
612            mode: BuildMode::Release,
613            env_vars: HashMap::new(),
614            features: vec![],
615            signing_profile: None,
616        });
617        assert!(validate_pipeline(&pipeline).is_err());
618    }
619
620    #[test]
621    fn test_build_status_terminal() {
622        assert!(!BuildStatus::Queued.is_terminal());
623        assert!(!BuildStatus::Running.is_terminal());
624        assert!(BuildStatus::Succeeded.is_terminal());
625        assert!(BuildStatus::Failed { reason: "e".into() }.is_terminal());
626        assert!(BuildStatus::Cancelled.is_terminal());
627    }
628
629    #[test]
630    fn test_pipeline_completion_tracking() {
631        let mut pipeline = BuildPipeline::new("p1", "App", "1.0.0");
632        pipeline.add_job(BuildJobConfig {
633            target: BuildTarget::Web,
634            mode: BuildMode::Debug,
635            env_vars: HashMap::new(),
636            features: vec![],
637            signing_profile: None,
638        });
639        assert!(!pipeline.is_complete());
640
641        pipeline.jobs[0].status = BuildStatus::Succeeded;
642        assert!(pipeline.is_complete());
643        assert!(pipeline.all_succeeded());
644
645        pipeline.jobs[0].status = BuildStatus::Failed { reason: "oom".into() };
646        assert!(pipeline.is_complete());
647        assert!(!pipeline.all_succeeded());
648    }
649
650    // ── OTA Update Tests ──
651
652    #[test]
653    fn test_version_ordering() {
654        let v1 = BundleVersion::new(1, 0, 0);
655        let v1_1 = BundleVersion::new(1, 1, 0);
656        let v2 = BundleVersion::new(2, 0, 0);
657
658        assert!(v1 < v1_1);
659        assert!(v1_1 < v2);
660        assert!(v1 < v2);
661    }
662
663    #[test]
664    fn test_version_compatibility() {
665        let v1_2_0 = BundleVersion::new(1, 2, 0);
666        let v1_1_0 = BundleVersion::new(1, 1, 0);
667        let v1_3_0 = BundleVersion::new(1, 3, 0);
668        let v2_0_0 = BundleVersion::new(2, 0, 0);
669
670        // Same major, self >= min → compatible
671        assert!(v1_2_0.is_compatible_with(&v1_1_0));
672        assert!(v1_2_0.is_compatible_with(&v1_2_0));
673        // Self < min → incompatible
674        assert!(!v1_1_0.is_compatible_with(&v1_2_0));
675        // Different major → incompatible
676        assert!(!v2_0_0.is_compatible_with(&v1_3_0));
677        assert!(!v1_3_0.is_compatible_with(&v2_0_0));
678    }
679
680    #[test]
681    fn test_version_display() {
682        assert_eq!(BundleVersion::new(1, 2, 3).to_string(), "1.2.3");
683        assert_eq!(BundleVersion::new(1, 0, 0).with_build(42).to_string(), "1.0.0+42");
684    }
685
686    #[test]
687    fn test_ota_no_updates() {
688        let manifest = OtaManifest::new("app1");
689        let current = BundleVersion::new(1, 0, 0);
690        let native = BundleVersion::new(1, 0, 0);
691        assert_eq!(evaluate_ota(&manifest, &current, &native, BuildTarget::Ios), OtaDecision::NoUpdate);
692    }
693
694    #[test]
695    fn test_ota_optional_update() {
696        let mut manifest = OtaManifest::new("app1");
697        manifest.updates.push(OtaUpdate {
698            version: BundleVersion::new(1, 1, 0),
699            min_native_version: BundleVersion::new(1, 0, 0),
700            bundle_url: "https://cdn.example.com/bundle-1.1.0.js".into(),
701            bundle_hash: "abc123".into(),
702            bundle_size_bytes: 1024,
703            release_notes: "Bug fixes".into(),
704            is_mandatory: false,
705            rollout_percentage: 100,
706            created_at: 1000.0,
707            target_platforms: vec![BuildTarget::Ios, BuildTarget::Android],
708        });
709
710        let current = BundleVersion::new(1, 0, 0);
711        let native = BundleVersion::new(1, 0, 0);
712        assert_eq!(
713            evaluate_ota(&manifest, &current, &native, BuildTarget::Ios),
714            OtaDecision::Optional { version: BundleVersion::new(1, 1, 0) }
715        );
716    }
717
718    #[test]
719    fn test_ota_mandatory_update() {
720        let mut manifest = OtaManifest::new("app1");
721        manifest.updates.push(OtaUpdate {
722            version: BundleVersion::new(1, 2, 0),
723            min_native_version: BundleVersion::new(1, 0, 0),
724            bundle_url: "https://cdn.example.com/b.js".into(),
725            bundle_hash: "def456".into(),
726            bundle_size_bytes: 2048,
727            release_notes: "Critical fix".into(),
728            is_mandatory: true,
729            rollout_percentage: 100,
730            created_at: 2000.0,
731            target_platforms: vec![BuildTarget::Ios],
732        });
733
734        let current = BundleVersion::new(1, 0, 0);
735        let native = BundleVersion::new(1, 0, 0);
736        assert_eq!(
737            evaluate_ota(&manifest, &current, &native, BuildTarget::Ios),
738            OtaDecision::Mandatory { version: BundleVersion::new(1, 2, 0) }
739        );
740    }
741
742    #[test]
743    fn test_ota_native_update_required() {
744        let mut manifest = OtaManifest::new("app1");
745        manifest.updates.push(OtaUpdate {
746            version: BundleVersion::new(2, 0, 0),
747            min_native_version: BundleVersion::new(2, 0, 0),
748            bundle_url: "https://cdn.example.com/b.js".into(),
749            bundle_hash: "ghi789".into(),
750            bundle_size_bytes: 4096,
751            release_notes: "Major update".into(),
752            is_mandatory: true,
753            rollout_percentage: 100,
754            created_at: 3000.0,
755            target_platforms: vec![BuildTarget::Ios],
756        });
757
758        let current = BundleVersion::new(1, 0, 0);
759        let native = BundleVersion::new(1, 0, 0);
760        assert_eq!(
761            evaluate_ota(&manifest, &current, &native, BuildTarget::Ios),
762            OtaDecision::NativeUpdateRequired { min_native: BundleVersion::new(2, 0, 0) }
763        );
764    }
765
766    #[test]
767    fn test_ota_already_latest() {
768        let mut manifest = OtaManifest::new("app1");
769        manifest.updates.push(OtaUpdate {
770            version: BundleVersion::new(1, 0, 0),
771            min_native_version: BundleVersion::new(1, 0, 0),
772            bundle_url: "https://cdn.example.com/b.js".into(),
773            bundle_hash: "x".into(),
774            bundle_size_bytes: 100,
775            release_notes: "".into(),
776            is_mandatory: false,
777            rollout_percentage: 100,
778            created_at: 0.0,
779            target_platforms: vec![BuildTarget::Ios],
780        });
781
782        let current = BundleVersion::new(1, 0, 0);
783        let native = BundleVersion::new(1, 0, 0);
784        assert_eq!(evaluate_ota(&manifest, &current, &native, BuildTarget::Ios), OtaDecision::NoUpdate);
785    }
786
787    // ── Artifact Cache Tests ──
788
789    #[test]
790    fn test_cache_insert_and_get() {
791        let mut cache = ArtifactCache::new(1_000_000);
792        let key = CacheKey::new("abc123", BuildTarget::Ios, BuildMode::Release);
793        let entry = CacheEntry {
794            key: key.clone(),
795            artifact_path: "/tmp/build.ipa".into(),
796            size_bytes: 5000,
797            created_at: 1000.0,
798            last_accessed: 1000.0,
799            ttl_hours: 24,
800            metadata: HashMap::new(),
801        };
802        cache.insert(entry);
803
804        assert_eq!(cache.len(), 1);
805        assert_eq!(cache.size_bytes(), 5000);
806        let hit = cache.get(&key, 2000.0);
807        assert!(hit.is_some());
808        assert_eq!(hit.unwrap().artifact_path, "/tmp/build.ipa");
809    }
810
811    #[test]
812    fn test_cache_expiration() {
813        let mut cache = ArtifactCache::new(1_000_000);
814        let key = CacheKey::new("abc", BuildTarget::Web, BuildMode::Debug);
815        let entry = CacheEntry {
816            key: key.clone(),
817            artifact_path: "/tmp/out".into(),
818            size_bytes: 100,
819            created_at: 0.0,
820            last_accessed: 0.0,
821            ttl_hours: 1,
822            metadata: HashMap::new(),
823        };
824        cache.insert(entry);
825
826        // Within TTL
827        assert!(cache.get(&key, 3_000_000.0).is_some());
828        // After TTL (1 hour = 3_600_000 ms)
829        assert!(cache.get(&key, 3_700_000.0).is_none());
830        assert_eq!(cache.len(), 0);
831    }
832
833    #[test]
834    fn test_cache_eviction() {
835        let mut cache = ArtifactCache::new(100);
836        for i in 0..5 {
837            let key = CacheKey::new(format!("hash{i}"), BuildTarget::Android, BuildMode::Release);
838            let entry = CacheEntry {
839                key,
840                artifact_path: format!("/tmp/out{i}"),
841                size_bytes: 30,
842                created_at: i as f64 * 1000.0,
843                last_accessed: i as f64 * 1000.0,
844                ttl_hours: 24,
845                metadata: HashMap::new(),
846            };
847            cache.insert(entry);
848        }
849
850        // Max 100 bytes, each entry 30 bytes → at most 3 entries
851        assert!(cache.len() <= 3);
852        assert!(cache.size_bytes() <= 100);
853    }
854
855    #[test]
856    fn test_cache_purge_expired() {
857        let mut cache = ArtifactCache::new(1_000_000);
858        for i in 0..3 {
859            let key = CacheKey::new(format!("h{i}"), BuildTarget::Macos, BuildMode::Debug);
860            let entry = CacheEntry {
861                key,
862                artifact_path: format!("/tmp/{i}"),
863                size_bytes: 10,
864                created_at: 0.0,
865                last_accessed: 0.0,
866                ttl_hours: 1,
867                metadata: HashMap::new(),
868            };
869            cache.insert(entry);
870        }
871
872        assert_eq!(cache.len(), 3);
873        let purged = cache.purge_expired(4_000_000.0);
874        assert_eq!(purged, 3);
875        assert_eq!(cache.len(), 0);
876    }
877
878    #[test]
879    fn test_cache_oversize_entry_rejected() {
880        let mut cache = ArtifactCache::new(100);
881        let key = CacheKey::new("big", BuildTarget::Linux, BuildMode::Release);
882        let entry = CacheEntry {
883            key: key.clone(),
884            artifact_path: "/tmp/huge".into(),
885            size_bytes: 200,
886            created_at: 0.0,
887            last_accessed: 0.0,
888            ttl_hours: 24,
889            metadata: HashMap::new(),
890        };
891        cache.insert(entry);
892        assert_eq!(cache.len(), 0); // rejected — too big
893    }
894
895    #[test]
896    fn test_cache_utilization() {
897        let mut cache = ArtifactCache::new(1000);
898        assert_eq!(cache.utilization(), 0.0);
899
900        let key = CacheKey::new("x", BuildTarget::Web, BuildMode::Release);
901        cache.insert(CacheEntry {
902            key,
903            artifact_path: "/tmp/x".into(),
904            size_bytes: 500,
905            created_at: 0.0,
906            last_accessed: 0.0,
907            ttl_hours: 24,
908            metadata: HashMap::new(),
909        });
910        assert!((cache.utilization() - 0.5).abs() < 0.001);
911    }
912
913    // ── Serialization Tests ──
914
915    #[test]
916    fn test_build_pipeline_serialization() {
917        let mut pipeline = BuildPipeline::new("p1", "TestApp", "2.0.0");
918        pipeline.add_job(BuildJobConfig {
919            target: BuildTarget::Ios,
920            mode: BuildMode::Release,
921            env_vars: HashMap::new(),
922            features: vec!["analytics".into()],
923            signing_profile: Some("dist".into()),
924        });
925
926        let json = serde_json::to_string(&pipeline).unwrap();
927        let restored: BuildPipeline = serde_json::from_str(&json).unwrap();
928        assert_eq!(restored.app_name, "TestApp");
929        assert_eq!(restored.jobs.len(), 1);
930        assert_eq!(restored.jobs[0].config.target, BuildTarget::Ios);
931    }
932
933    #[test]
934    fn test_ota_manifest_serialization() {
935        let mut manifest = OtaManifest::new("com.example.app");
936        manifest.updates.push(OtaUpdate {
937            version: BundleVersion::new(1, 0, 1),
938            min_native_version: BundleVersion::new(1, 0, 0),
939            bundle_url: "https://cdn.example.com/b.js".into(),
940            bundle_hash: "sha256-abc".into(),
941            bundle_size_bytes: 512,
942            release_notes: "Patch".into(),
943            is_mandatory: false,
944            rollout_percentage: 50,
945            created_at: 100.0,
946            target_platforms: vec![BuildTarget::Ios, BuildTarget::Android],
947        });
948
949        let json = serde_json::to_string(&manifest).unwrap();
950        let restored: OtaManifest = serde_json::from_str(&json).unwrap();
951        assert_eq!(restored.app_id, "com.example.app");
952        assert_eq!(restored.updates.len(), 1);
953        assert_eq!(restored.updates[0].rollout_percentage, 50);
954    }
955
956    #[test]
957    fn test_build_target_platform_id_mapping() {
958        assert_eq!(BuildTarget::Ios.to_platform_id(), Some(PlatformId::Ios));
959        assert_eq!(BuildTarget::Android.to_platform_id(), Some(PlatformId::Android));
960        assert_eq!(BuildTarget::Web.to_platform_id(), Some(PlatformId::Web));
961        assert_eq!(BuildTarget::Linux.to_platform_id(), None);
962    }
963}