ricecoder_learning/
rule_promoter.rs

1/// Rule promotion workflow for promoting rules from project to global scope
2use crate::conflict_resolver::ConflictResolver;
3use crate::error::{LearningError, Result};
4use crate::models::{Rule, RuleScope, RuleSource};
5use chrono::Utc;
6use std::collections::HashMap;
7
8/// Metadata about a rule promotion
9#[derive(Debug, Clone)]
10pub struct PromotionMetadata {
11    /// When the promotion was requested
12    pub requested_at: chrono::DateTime<chrono::Utc>,
13    /// When the promotion was completed (if approved)
14    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
15    /// Whether the promotion was approved
16    pub approved: bool,
17    /// Reason for approval or rejection
18    pub reason: Option<String>,
19    /// Previous version of the rule (before promotion)
20    pub previous_version: Option<Rule>,
21}
22
23/// Information about a rule for review
24#[derive(Debug, Clone)]
25pub struct RuleReview {
26    /// The rule being reviewed
27    pub rule: Rule,
28    /// Metadata about the promotion
29    pub promotion_metadata: PromotionMetadata,
30    /// Conflicts detected with existing global rules
31    pub conflicts: Vec<Rule>,
32    /// Comparison with previous version
33    pub version_changes: Option<VersionChanges>,
34}
35
36/// Changes between rule versions
37#[derive(Debug, Clone)]
38pub struct VersionChanges {
39    /// Previous pattern
40    pub previous_pattern: String,
41    /// New pattern
42    pub new_pattern: String,
43    /// Previous action
44    pub previous_action: String,
45    /// New action
46    pub new_action: String,
47    /// Previous confidence
48    pub previous_confidence: f32,
49    /// New confidence
50    pub new_confidence: f32,
51}
52
53/// Promotion history entry
54#[derive(Debug, Clone)]
55pub struct PromotionHistoryEntry {
56    /// Rule ID
57    pub rule_id: String,
58    /// Source scope
59    pub source_scope: RuleScope,
60    /// Target scope
61    pub target_scope: RuleScope,
62    /// When the promotion occurred
63    pub promoted_at: chrono::DateTime<chrono::Utc>,
64    /// Whether it was approved
65    pub approved: bool,
66    /// Reason for approval or rejection
67    pub reason: Option<String>,
68}
69
70/// Manages rule promotion from project to global scope
71pub struct RulePromoter {
72    /// Promotion history
73    promotion_history: Vec<PromotionHistoryEntry>,
74    /// Pending promotions awaiting approval
75    pending_promotions: HashMap<String, RuleReview>,
76}
77
78impl RulePromoter {
79    /// Create a new rule promoter
80    pub fn new() -> Self {
81        Self {
82            promotion_history: Vec::new(),
83            pending_promotions: HashMap::new(),
84        }
85    }
86
87    /// Request promotion of a rule from project to global scope
88    pub fn request_promotion(
89        &mut self,
90        rule: Rule,
91        global_rules: &[Rule],
92    ) -> Result<RuleReview> {
93        // Validate that the rule is from project scope
94        if rule.scope != RuleScope::Project {
95            return Err(LearningError::RulePromotionFailed(
96                format!(
97                    "Can only promote rules from project scope, rule is in {} scope",
98                    rule.scope
99                ),
100            ));
101        }
102
103        // Check for conflicts with existing global rules
104        let conflicts = self.detect_conflicts(&rule, global_rules)?;
105
106        // Create promotion metadata
107        let promotion_metadata = PromotionMetadata {
108            requested_at: Utc::now(),
109            completed_at: None,
110            approved: false,
111            reason: None,
112            previous_version: None,
113        };
114
115        // Create rule review
116        let rule_review = RuleReview {
117            rule: rule.clone(),
118            promotion_metadata,
119            conflicts,
120            version_changes: None,
121        };
122
123        // Store in pending promotions
124        self.pending_promotions
125            .insert(rule.id.clone(), rule_review.clone());
126
127        Ok(rule_review)
128    }
129
130    /// Detect conflicts between a rule and existing global rules
131    fn detect_conflicts(&self, rule: &Rule, global_rules: &[Rule]) -> Result<Vec<Rule>> {
132        let mut conflicts = Vec::new();
133
134        for global_rule in global_rules {
135            if ConflictResolver::detect_conflict(rule, global_rule) {
136                conflicts.push(global_rule.clone());
137            }
138        }
139
140        Ok(conflicts)
141    }
142
143    /// Approve a pending promotion
144    pub fn approve_promotion(
145        &mut self,
146        rule_id: &str,
147        reason: Option<String>,
148    ) -> Result<Rule> {
149        let mut rule_review = self
150            .pending_promotions
151            .remove(rule_id)
152            .ok_or_else(|| {
153                LearningError::RulePromotionFailed(format!(
154                    "No pending promotion found for rule '{}'",
155                    rule_id
156                ))
157            })?;
158
159        // Update the rule to be in global scope and mark as promoted
160        let mut promoted_rule = rule_review.rule.clone();
161        promoted_rule.scope = RuleScope::Global;
162        promoted_rule.source = RuleSource::Promoted;
163        promoted_rule.version += 1;
164        promoted_rule.updated_at = Utc::now();
165
166        // Update promotion metadata
167        rule_review.promotion_metadata.approved = true;
168        rule_review.promotion_metadata.completed_at = Some(Utc::now());
169        rule_review.promotion_metadata.reason = reason.clone();
170
171        // Record in promotion history
172        self.promotion_history.push(PromotionHistoryEntry {
173            rule_id: promoted_rule.id.clone(),
174            source_scope: RuleScope::Project,
175            target_scope: RuleScope::Global,
176            promoted_at: Utc::now(),
177            approved: true,
178            reason,
179        });
180
181        Ok(promoted_rule)
182    }
183
184    /// Reject a pending promotion
185    pub fn reject_promotion(
186        &mut self,
187        rule_id: &str,
188        reason: Option<String>,
189    ) -> Result<()> {
190        let mut rule_review = self
191            .pending_promotions
192            .remove(rule_id)
193            .ok_or_else(|| {
194                LearningError::RulePromotionFailed(format!(
195                    "No pending promotion found for rule '{}'",
196                    rule_id
197                ))
198            })?;
199
200        // Update promotion metadata
201        rule_review.promotion_metadata.approved = false;
202        rule_review.promotion_metadata.completed_at = Some(Utc::now());
203        rule_review.promotion_metadata.reason = reason.clone();
204
205        // Record in promotion history
206        self.promotion_history.push(PromotionHistoryEntry {
207            rule_id: rule_review.rule.id.clone(),
208            source_scope: RuleScope::Project,
209            target_scope: RuleScope::Global,
210            promoted_at: Utc::now(),
211            approved: false,
212            reason,
213        });
214
215        Ok(())
216    }
217
218    /// Get a pending promotion for review
219    pub fn get_pending_promotion(&self, rule_id: &str) -> Result<RuleReview> {
220        self.pending_promotions
221            .get(rule_id)
222            .cloned()
223            .ok_or_else(|| {
224                LearningError::RulePromotionFailed(format!(
225                    "No pending promotion found for rule '{}'",
226                    rule_id
227                ))
228            })
229    }
230
231    /// Get all pending promotions
232    pub fn get_pending_promotions(&self) -> Vec<RuleReview> {
233        self.pending_promotions.values().cloned().collect()
234    }
235
236    /// Get the number of pending promotions
237    pub fn pending_promotion_count(&self) -> usize {
238        self.pending_promotions.len()
239    }
240
241    /// Get promotion history
242    pub fn get_promotion_history(&self) -> Vec<PromotionHistoryEntry> {
243        self.promotion_history.clone()
244    }
245
246    /// Get promotion history for a specific rule
247    pub fn get_promotion_history_for_rule(&self, rule_id: &str) -> Vec<PromotionHistoryEntry> {
248        self.promotion_history
249            .iter()
250            .filter(|entry| entry.rule_id == rule_id)
251            .cloned()
252            .collect()
253    }
254
255    /// Get promotion history for a specific scope
256    pub fn get_promotion_history_for_scope(
257        &self,
258        source_scope: RuleScope,
259        target_scope: RuleScope,
260    ) -> Vec<PromotionHistoryEntry> {
261        self.promotion_history
262            .iter()
263            .filter(|entry| entry.source_scope == source_scope && entry.target_scope == target_scope)
264            .cloned()
265            .collect()
266    }
267
268    /// Get approved promotions from history
269    pub fn get_approved_promotions(&self) -> Vec<PromotionHistoryEntry> {
270        self.promotion_history
271            .iter()
272            .filter(|entry| entry.approved)
273            .cloned()
274            .collect()
275    }
276
277    /// Get rejected promotions from history
278    pub fn get_rejected_promotions(&self) -> Vec<PromotionHistoryEntry> {
279        self.promotion_history
280            .iter()
281            .filter(|entry| !entry.approved)
282            .cloned()
283            .collect()
284    }
285
286    /// Validate a promoted rule against global rules
287    pub fn validate_promotion(
288        &self,
289        promoted_rule: &Rule,
290        global_rules: &[Rule],
291    ) -> Result<()> {
292        // Check that the rule is in global scope
293        if promoted_rule.scope != RuleScope::Global {
294            return Err(LearningError::RulePromotionFailed(
295                "Promoted rule must be in global scope".to_string(),
296            ));
297        }
298
299        // Check that the rule source is Promoted
300        if promoted_rule.source != RuleSource::Promoted {
301            return Err(LearningError::RulePromotionFailed(
302                "Promoted rule must have source 'Promoted'".to_string(),
303            ));
304        }
305
306        // Check for conflicts with existing global rules
307        for global_rule in global_rules {
308            if global_rule.id != promoted_rule.id
309                && ConflictResolver::detect_conflict(promoted_rule, global_rule)
310            {
311                return Err(LearningError::RulePromotionFailed(
312                    format!(
313                        "Promoted rule conflicts with existing global rule '{}': both match pattern '{}' but have different actions",
314                        global_rule.id, promoted_rule.pattern
315                    ),
316                ));
317            }
318        }
319
320        Ok(())
321    }
322
323    /// Compare two versions of a rule
324    pub fn compare_versions(previous: &Rule, current: &Rule) -> VersionChanges {
325        VersionChanges {
326            previous_pattern: previous.pattern.clone(),
327            new_pattern: current.pattern.clone(),
328            previous_action: previous.action.clone(),
329            new_action: current.action.clone(),
330            previous_confidence: previous.confidence,
331            new_confidence: current.confidence,
332        }
333    }
334
335    /// Create a rule review with version comparison
336    pub fn create_review_with_comparison(
337        rule: Rule,
338        previous_version: Option<Rule>,
339        global_rules: &[Rule],
340    ) -> Result<RuleReview> {
341        let conflicts = ConflictResolver::find_conflicts(&[rule.clone()])
342            .into_iter()
343            .filter(|(r1, r2)| {
344                global_rules.iter().any(|gr| {
345                    (gr.id == r1.id || gr.id == r2.id)
346                        && (gr.id != rule.id)
347                })
348            })
349            .flat_map(|(r1, r2)| vec![r1, r2])
350            .collect::<Vec<_>>();
351
352        let version_changes = previous_version.as_ref().map(|prev| {
353            Self::compare_versions(prev, &rule)
354        });
355
356        let promotion_metadata = PromotionMetadata {
357            requested_at: Utc::now(),
358            completed_at: None,
359            approved: false,
360            reason: None,
361            previous_version,
362        };
363
364        Ok(RuleReview {
365            rule,
366            promotion_metadata,
367            conflicts,
368            version_changes,
369        })
370    }
371
372    /// Clear all pending promotions
373    pub fn clear_pending_promotions(&mut self) {
374        self.pending_promotions.clear();
375    }
376
377    /// Clear promotion history
378    pub fn clear_promotion_history(&mut self) {
379        self.promotion_history.clear();
380    }
381}
382
383impl Default for RulePromoter {
384    fn default() -> Self {
385        Self::new()
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::models::Rule;
393
394    #[test]
395    fn test_rule_promoter_creation() {
396        let promoter = RulePromoter::new();
397        assert_eq!(promoter.pending_promotion_count(), 0);
398        assert_eq!(promoter.get_promotion_history().len(), 0);
399    }
400
401    #[test]
402    fn test_request_promotion() {
403        let mut promoter = RulePromoter::new();
404
405        let rule = Rule::new(
406            RuleScope::Project,
407            "pattern".to_string(),
408            "action".to_string(),
409            RuleSource::Learned,
410        );
411
412        let rule_id = rule.id.clone();
413        let result = promoter.request_promotion(rule, &[]);
414        assert!(result.is_ok());
415
416        let review = result.unwrap();
417        assert_eq!(review.rule.id, rule_id);
418        assert!(!review.promotion_metadata.approved);
419        assert_eq!(promoter.pending_promotion_count(), 1);
420    }
421
422    #[test]
423    fn test_request_promotion_wrong_scope() {
424        let mut promoter = RulePromoter::new();
425
426        let rule = Rule::new(
427            RuleScope::Global,
428            "pattern".to_string(),
429            "action".to_string(),
430            RuleSource::Learned,
431        );
432
433        let result = promoter.request_promotion(rule, &[]);
434        assert!(result.is_err());
435    }
436
437    #[test]
438    fn test_approve_promotion() {
439        let mut promoter = RulePromoter::new();
440
441        let rule = Rule::new(
442            RuleScope::Project,
443            "pattern".to_string(),
444            "action".to_string(),
445            RuleSource::Learned,
446        );
447
448        let rule_id = rule.id.clone();
449        promoter.request_promotion(rule, &[]).unwrap();
450
451        let result = promoter.approve_promotion(&rule_id, Some("Looks good".to_string()));
452        assert!(result.is_ok());
453
454        let promoted_rule = result.unwrap();
455        assert_eq!(promoted_rule.scope, RuleScope::Global);
456        assert_eq!(promoted_rule.source, RuleSource::Promoted);
457        assert_eq!(promoted_rule.version, 2);
458
459        assert_eq!(promoter.pending_promotion_count(), 0);
460        assert_eq!(promoter.get_promotion_history().len(), 1);
461    }
462
463    #[test]
464    fn test_reject_promotion() {
465        let mut promoter = RulePromoter::new();
466
467        let rule = Rule::new(
468            RuleScope::Project,
469            "pattern".to_string(),
470            "action".to_string(),
471            RuleSource::Learned,
472        );
473
474        let rule_id = rule.id.clone();
475        promoter.request_promotion(rule, &[]).unwrap();
476
477        let result = promoter.reject_promotion(&rule_id, Some("Not ready".to_string()));
478        assert!(result.is_ok());
479
480        assert_eq!(promoter.pending_promotion_count(), 0);
481        assert_eq!(promoter.get_promotion_history().len(), 1);
482
483        let history = promoter.get_promotion_history();
484        assert!(!history[0].approved);
485    }
486
487    #[test]
488    fn test_get_pending_promotion() {
489        let mut promoter = RulePromoter::new();
490
491        let rule = Rule::new(
492            RuleScope::Project,
493            "pattern".to_string(),
494            "action".to_string(),
495            RuleSource::Learned,
496        );
497
498        let rule_id = rule.id.clone();
499        promoter.request_promotion(rule, &[]).unwrap();
500
501        let result = promoter.get_pending_promotion(&rule_id);
502        assert!(result.is_ok());
503
504        let review = result.unwrap();
505        assert_eq!(review.rule.id, rule_id);
506    }
507
508    #[test]
509    fn test_get_pending_promotions() {
510        let mut promoter = RulePromoter::new();
511
512        let rule1 = Rule::new(
513            RuleScope::Project,
514            "pattern1".to_string(),
515            "action1".to_string(),
516            RuleSource::Learned,
517        );
518
519        let rule2 = Rule::new(
520            RuleScope::Project,
521            "pattern2".to_string(),
522            "action2".to_string(),
523            RuleSource::Learned,
524        );
525
526        promoter.request_promotion(rule1, &[]).unwrap();
527        promoter.request_promotion(rule2, &[]).unwrap();
528
529        let pending = promoter.get_pending_promotions();
530        assert_eq!(pending.len(), 2);
531    }
532
533    #[test]
534    fn test_detect_conflicts() {
535        let promoter = RulePromoter::new();
536
537        let project_rule = Rule::new(
538            RuleScope::Project,
539            "pattern".to_string(),
540            "action1".to_string(),
541            RuleSource::Learned,
542        );
543
544        let global_rule = Rule::new(
545            RuleScope::Global,
546            "pattern".to_string(),
547            "action2".to_string(),
548            RuleSource::Learned,
549        );
550
551        let conflicts = promoter.detect_conflicts(&project_rule, &[global_rule]).unwrap();
552        assert_eq!(conflicts.len(), 1);
553    }
554
555    #[test]
556    fn test_validate_promotion() {
557        let promoter = RulePromoter::new();
558
559        let mut promoted_rule = Rule::new(
560            RuleScope::Global,
561            "pattern".to_string(),
562            "action".to_string(),
563            RuleSource::Promoted,
564        );
565
566        let result = promoter.validate_promotion(&promoted_rule, &[]);
567        assert!(result.is_ok());
568
569        // Test with wrong scope
570        promoted_rule.scope = RuleScope::Project;
571        let result = promoter.validate_promotion(&promoted_rule, &[]);
572        assert!(result.is_err());
573    }
574
575    #[test]
576    fn test_compare_versions() {
577        let mut previous = Rule::new(
578            RuleScope::Project,
579            "old_pattern".to_string(),
580            "old_action".to_string(),
581            RuleSource::Learned,
582        );
583        previous.confidence = 0.5;
584
585        let mut current = Rule::new(
586            RuleScope::Project,
587            "new_pattern".to_string(),
588            "new_action".to_string(),
589            RuleSource::Learned,
590        );
591        current.confidence = 0.8;
592
593        let changes = RulePromoter::compare_versions(&previous, &current);
594        assert_eq!(changes.previous_pattern, "old_pattern");
595        assert_eq!(changes.new_pattern, "new_pattern");
596        assert_eq!(changes.previous_confidence, 0.5);
597        assert_eq!(changes.new_confidence, 0.8);
598    }
599
600    #[test]
601    fn test_promotion_history() {
602        let mut promoter = RulePromoter::new();
603
604        let rule = Rule::new(
605            RuleScope::Project,
606            "pattern".to_string(),
607            "action".to_string(),
608            RuleSource::Learned,
609        );
610
611        let rule_id = rule.id.clone();
612        promoter.request_promotion(rule, &[]).unwrap();
613        promoter.approve_promotion(&rule_id, None).unwrap();
614
615        let history = promoter.get_promotion_history();
616        assert_eq!(history.len(), 1);
617        assert!(history[0].approved);
618
619        let rule_history = promoter.get_promotion_history_for_rule(&rule_id);
620        assert_eq!(rule_history.len(), 1);
621    }
622
623    #[test]
624    fn test_get_approved_promotions() {
625        let mut promoter = RulePromoter::new();
626
627        let rule1 = Rule::new(
628            RuleScope::Project,
629            "pattern1".to_string(),
630            "action1".to_string(),
631            RuleSource::Learned,
632        );
633
634        let rule2 = Rule::new(
635            RuleScope::Project,
636            "pattern2".to_string(),
637            "action2".to_string(),
638            RuleSource::Learned,
639        );
640
641        let rule1_id = rule1.id.clone();
642        let rule2_id = rule2.id.clone();
643
644        promoter.request_promotion(rule1, &[]).unwrap();
645        promoter.request_promotion(rule2, &[]).unwrap();
646
647        promoter.approve_promotion(&rule1_id, None).unwrap();
648        promoter.reject_promotion(&rule2_id, None).unwrap();
649
650        let approved = promoter.get_approved_promotions();
651        assert_eq!(approved.len(), 1);
652        assert!(approved[0].approved);
653
654        let rejected = promoter.get_rejected_promotions();
655        assert_eq!(rejected.len(), 1);
656        assert!(!rejected[0].approved);
657    }
658
659    #[test]
660    fn test_clear_pending_promotions() {
661        let mut promoter = RulePromoter::new();
662
663        let rule = Rule::new(
664            RuleScope::Project,
665            "pattern".to_string(),
666            "action".to_string(),
667            RuleSource::Learned,
668        );
669
670        promoter.request_promotion(rule, &[]).unwrap();
671        assert_eq!(promoter.pending_promotion_count(), 1);
672
673        promoter.clear_pending_promotions();
674        assert_eq!(promoter.pending_promotion_count(), 0);
675    }
676
677    #[test]
678    fn test_clear_promotion_history() {
679        let mut promoter = RulePromoter::new();
680
681        let rule = Rule::new(
682            RuleScope::Project,
683            "pattern".to_string(),
684            "action".to_string(),
685            RuleSource::Learned,
686        );
687
688        let rule_id = rule.id.clone();
689        promoter.request_promotion(rule, &[]).unwrap();
690        promoter.approve_promotion(&rule_id, None).unwrap();
691
692        assert_eq!(promoter.get_promotion_history().len(), 1);
693
694        promoter.clear_promotion_history();
695        assert_eq!(promoter.get_promotion_history().len(), 0);
696    }
697}