1use crate::platform::PlatformId;
15use serde::{Serialize, Deserialize};
16use std::collections::HashMap;
17
18#[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#[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, }
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
75pub enum BuildMode {
76 Debug,
77 Release,
78 Profile,
79}
80
81#[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#[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#[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#[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 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 pub fn is_complete(&self) -> bool {
181 !self.jobs.is_empty() && self.jobs.iter().all(|j| j.status.is_terminal())
182 }
183
184 pub fn all_succeeded(&self) -> bool {
186 !self.jobs.is_empty() && self.jobs.iter().all(|j| j.status.is_success())
187 }
188
189 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 pub fn targets(&self) -> Vec<BuildTarget> {
198 self.jobs.iter().map(|j| j.config.target).collect()
199 }
200}
201
202pub 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 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#[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 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#[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#[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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
346pub enum OtaDecision {
347 NoUpdate,
349 Optional { version: BundleVersion },
351 Mandatory { version: BundleVersion },
353 NativeUpdateRequired { min_native: BundleVersion },
355}
356
357pub fn evaluate_ota(
359 manifest: &OtaManifest,
360 current_bundle: &BundleVersion,
361 current_native: &BundleVersion,
362 platform: BuildTarget,
363) -> OtaDecision {
364 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 current_bundle >= &newest.version {
375 return OtaDecision::NoUpdate;
376 }
377
378 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 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
404pub struct CacheKey {
405 pub content_hash: String,
407 pub target: BuildTarget,
409 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#[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 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
449pub 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 pub fn get(&mut self, key: &CacheKey, now: f64) -> Option<&CacheEntry> {
467 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 if let Some(entry) = self.entries.get_mut(key) {
478 entry.last_accessed = now;
479 }
480 self.entries.get(key)
481 }
482
483 pub fn insert(&mut self, entry: CacheEntry) {
485 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 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 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 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 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 pub fn len(&self) -> usize {
533 self.entries.len()
534 }
535
536 pub fn is_empty(&self) -> bool {
538 self.entries.is_empty()
539 }
540
541 pub fn size_bytes(&self) -> u64 {
543 self.current_size_bytes
544 }
545
546 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#[cfg(test)]
560mod tests {
561 use super::*;
562
563 #[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 #[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 assert!(v1_2_0.is_compatible_with(&v1_1_0));
672 assert!(v1_2_0.is_compatible_with(&v1_2_0));
673 assert!(!v1_1_0.is_compatible_with(&v1_2_0));
675 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, ¤t, &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, ¤t, &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, ¤t, &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, ¤t, &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, ¤t, &native, BuildTarget::Ios), OtaDecision::NoUpdate);
785 }
786
787 #[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 assert!(cache.get(&key, 3_000_000.0).is_some());
828 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 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); }
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 #[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}