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!("bundles: {} → {}", a.bundles.len(), b.bundles.len()));
178    }
179    if a.catalog_rules.len() != b.catalog_rules.len() {
180        diffs.push(format!(
181            "catalog_rules: {} → {}",
182            a.catalog_rules.len(),
183            b.catalog_rules.len()
184        ));
185    }
186    if a.relationships.len() != b.relationships.len() {
187        diffs.push(format!(
188            "relationships: {} → {}",
189            a.relationships.len(),
190            b.relationships.len()
191        ));
192    }
193
194    let a_price_ids: std::collections::HashSet<_> =
195        a.pricing_rules.iter().map(|r| r.id).collect();
196    let b_price_ids: std::collections::HashSet<_> =
197        b.pricing_rules.iter().map(|r| r.id).collect();
198    for id in a_price_ids.difference(&b_price_ids) {
199        diffs.push(format!("pricing_rule removed: {id}"));
200    }
201    for id in b_price_ids.difference(&a_price_ids) {
202        diffs.push(format!("pricing_rule added: {id}"));
203    }
204
205    diffs
206}
207
208/// Version comparison result
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct VersionDiff {
211    pub version_1: CatalogVersion,
212    pub version_2: CatalogVersion,
213    pub differences: Vec<String>,
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::pricing::{Money, PriceType, PricingRule};
220
221    #[test]
222    fn publish_and_diff_snapshots() {
223        let catalog_id = Uuid::new_v4();
224        let mut vm = VersionManager::new();
225        let empty = CatalogSnapshot::default();
226        let v1 = vm.create_version(catalog_id, "1.0.0".into(), None, None, empty);
227
228        let mut snap2 = CatalogSnapshot::default();
229        snap2.pricing_rules.push(PricingRule {
230            id: Uuid::new_v4(),
231            product_offering_id: Uuid::new_v4(),
232            price_type: PriceType::OneTime,
233            base_price: Money {
234                value: 10.0,
235                unit: "USD".into(),
236            },
237            priority: 1,
238            discount_rules: None,
239            valid_for: None,
240        });
241        let v2 = vm.create_version(
242            catalog_id,
243            "1.1.0".into(),
244            Some("add price".into()),
245            None,
246            snap2,
247        );
248
249        vm.publish_version(v2.id).unwrap();
250        assert_eq!(
251            vm.get_active_version(catalog_id).unwrap().version,
252            "1.1.0"
253        );
254
255        let diff = vm.compare_versions(v1.id, v2.id).unwrap();
256        assert!(!diff.differences.is_empty());
257    }
258}