Skip to main content

pcm_engine/
versioning.rs

1//! Catalog Versioning System
2//!
3//! Manages catalog versions with content snapshots, publish/rollback, and diffs.
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::bundling::{Bundle, ProductRelationship};
10use crate::eligibility::EligibilityRule;
11use crate::pricing::PricingRule;
12use crate::rules::CatalogRule;
13
14/// Immutable snapshot of catalog content at a version point.
15#[derive(Debug, Clone, Serialize, Deserialize, Default)]
16pub struct CatalogSnapshot {
17    pub pricing_rules: Vec<PricingRule>,
18    pub eligibility_rules: Vec<EligibilityRule>,
19    pub bundles: Vec<Bundle>,
20    pub catalog_rules: Vec<CatalogRule>,
21    pub relationships: Vec<ProductRelationship>,
22}
23
24/// Catalog version metadata + optional content snapshot.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct CatalogVersion {
27    pub id: Uuid,
28    pub catalog_id: Uuid,
29    pub version: String,
30    pub description: Option<String>,
31    pub created_at: DateTime<Utc>,
32    pub created_by: Option<Uuid>,
33    pub is_active: bool,
34    pub is_published: bool,
35    pub published_at: Option<DateTime<Utc>>,
36    pub metadata: Option<serde_json::Value>,
37    /// Content captured when the version was created or updated.
38    #[serde(default)]
39    pub snapshot: CatalogSnapshot,
40}
41
42/// Version manager for catalogs
43#[derive(Default)]
44pub struct VersionManager {
45    versions: Vec<CatalogVersion>,
46}
47
48impl VersionManager {
49    /// Create a new version manager
50    pub fn new() -> Self {
51        Self {
52            versions: Vec::new(),
53        }
54    }
55
56    /// Create a new version with a content snapshot.
57    pub fn create_version(
58        &mut self,
59        catalog_id: Uuid,
60        version: String,
61        description: Option<String>,
62        created_by: Option<Uuid>,
63        snapshot: CatalogSnapshot,
64    ) -> CatalogVersion {
65        let catalog_version = CatalogVersion {
66            id: Uuid::new_v4(),
67            catalog_id,
68            version,
69            description,
70            created_at: Utc::now(),
71            created_by,
72            is_active: false,
73            is_published: false,
74            published_at: None,
75            metadata: None,
76            snapshot,
77        };
78        self.versions.push(catalog_version.clone());
79        catalog_version
80    }
81
82    /// Publish a version (makes it the sole active version for the catalog).
83    pub fn publish_version(&mut self, version_id: Uuid) -> Result<&CatalogVersion, String> {
84        let catalog_id = {
85            let version = self
86                .versions
87                .iter()
88                .find(|v| v.id == version_id)
89                .ok_or_else(|| "Version not found".to_string())?;
90            version.catalog_id
91        };
92
93        for v in self.versions.iter_mut() {
94            if v.catalog_id == catalog_id && v.id != version_id {
95                v.is_active = false;
96            }
97        }
98
99        let version = self
100            .versions
101            .iter_mut()
102            .find(|v| v.id == version_id)
103            .ok_or_else(|| "Version not found".to_string())?;
104        version.is_active = true;
105        version.is_published = true;
106        version.published_at = Some(Utc::now());
107        Ok(version)
108    }
109
110    /// Rollback to a previous version (re-publishes it).
111    pub fn rollback_to_version(&mut self, version_id: Uuid) -> Result<&CatalogVersion, String> {
112        self.publish_version(version_id)
113    }
114
115    /// Get active version for a catalog
116    pub fn get_active_version(&self, catalog_id: Uuid) -> Option<&CatalogVersion> {
117        self.versions
118            .iter()
119            .find(|v| v.catalog_id == catalog_id && v.is_active)
120    }
121
122    /// Get snapshot of the active catalog version.
123    pub fn get_active_snapshot(&self, catalog_id: Uuid) -> Option<&CatalogSnapshot> {
124        self.get_active_version(catalog_id).map(|v| &v.snapshot)
125    }
126
127    /// Get all versions for a catalog
128    pub fn get_versions(&self, catalog_id: Uuid) -> Vec<&CatalogVersion> {
129        self.versions
130            .iter()
131            .filter(|v| v.catalog_id == catalog_id)
132            .collect()
133    }
134
135    /// Compare two versions (content-aware diff).
136    pub fn compare_versions(
137        &self,
138        version_id_1: Uuid,
139        version_id_2: Uuid,
140    ) -> Result<VersionDiff, String> {
141        let v1 = self
142            .versions
143            .iter()
144            .find(|v| v.id == version_id_1)
145            .ok_or_else(|| "Version 1 not found".to_string())?;
146        let v2 = self
147            .versions
148            .iter()
149            .find(|v| v.id == version_id_2)
150            .ok_or_else(|| "Version 2 not found".to_string())?;
151
152        Ok(VersionDiff {
153            version_1: v1.clone(),
154            version_2: v2.clone(),
155            differences: diff_snapshots(&v1.snapshot, &v2.snapshot),
156        })
157    }
158}
159
160fn diff_snapshots(a: &CatalogSnapshot, b: &CatalogSnapshot) -> Vec<String> {
161    let mut diffs = Vec::new();
162    if a.pricing_rules.len() != b.pricing_rules.len() {
163        diffs.push(format!(
164            "pricing_rules: {} → {}",
165            a.pricing_rules.len(),
166            b.pricing_rules.len()
167        ));
168    }
169    if a.eligibility_rules.len() != b.eligibility_rules.len() {
170        diffs.push(format!(
171            "eligibility_rules: {} → {}",
172            a.eligibility_rules.len(),
173            b.eligibility_rules.len()
174        ));
175    }
176    if a.bundles.len() != b.bundles.len() {
177        diffs.push(format!(
178            "bundles: {} → {}",
179            a.bundles.len(),
180            b.bundles.len()
181        ));
182    }
183    if a.catalog_rules.len() != b.catalog_rules.len() {
184        diffs.push(format!(
185            "catalog_rules: {} → {}",
186            a.catalog_rules.len(),
187            b.catalog_rules.len()
188        ));
189    }
190    if a.relationships.len() != b.relationships.len() {
191        diffs.push(format!(
192            "relationships: {} → {}",
193            a.relationships.len(),
194            b.relationships.len()
195        ));
196    }
197
198    let a_price_ids: std::collections::HashSet<_> = a.pricing_rules.iter().map(|r| r.id).collect();
199    let b_price_ids: std::collections::HashSet<_> = b.pricing_rules.iter().map(|r| r.id).collect();
200    for id in a_price_ids.difference(&b_price_ids) {
201        diffs.push(format!("pricing_rule removed: {id}"));
202    }
203    for id in b_price_ids.difference(&a_price_ids) {
204        diffs.push(format!("pricing_rule added: {id}"));
205    }
206
207    diffs
208}
209
210/// Version comparison result
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct VersionDiff {
213    pub version_1: CatalogVersion,
214    pub version_2: CatalogVersion,
215    pub differences: Vec<String>,
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::pricing::{Money, PriceType, PricingRule};
222
223    #[test]
224    fn publish_and_diff_snapshots() {
225        let catalog_id = Uuid::new_v4();
226        let mut vm = VersionManager::new();
227        let empty = CatalogSnapshot::default();
228        let v1 = vm.create_version(catalog_id, "1.0.0".into(), None, None, empty);
229
230        let mut snap2 = CatalogSnapshot::default();
231        snap2.pricing_rules.push(PricingRule {
232            id: Uuid::new_v4(),
233            product_offering_id: Uuid::new_v4(),
234            price_type: PriceType::OneTime,
235            base_price: Money {
236                value: 10.0,
237                unit: "USD".into(),
238            },
239            priority: 1,
240            discount_rules: None,
241            valid_for: None,
242        });
243        let v2 = vm.create_version(
244            catalog_id,
245            "1.1.0".into(),
246            Some("add price".into()),
247            None,
248            snap2,
249        );
250
251        vm.publish_version(v2.id).unwrap();
252        assert_eq!(vm.get_active_version(catalog_id).unwrap().version, "1.1.0");
253
254        let diff = vm.compare_versions(v1.id, v2.id).unwrap();
255        assert!(!diff.differences.is_empty());
256    }
257}