1use crate::conflict_resolver::ConflictResolver;
3use crate::error::{LearningError, Result};
4use crate::models::{Rule, RuleScope, RuleSource};
5use chrono::Utc;
6use std::collections::HashMap;
7
8#[derive(Debug, Clone)]
10pub struct PromotionMetadata {
11 pub requested_at: chrono::DateTime<chrono::Utc>,
13 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
15 pub approved: bool,
17 pub reason: Option<String>,
19 pub previous_version: Option<Rule>,
21}
22
23#[derive(Debug, Clone)]
25pub struct RuleReview {
26 pub rule: Rule,
28 pub promotion_metadata: PromotionMetadata,
30 pub conflicts: Vec<Rule>,
32 pub version_changes: Option<VersionChanges>,
34}
35
36#[derive(Debug, Clone)]
38pub struct VersionChanges {
39 pub previous_pattern: String,
41 pub new_pattern: String,
43 pub previous_action: String,
45 pub new_action: String,
47 pub previous_confidence: f32,
49 pub new_confidence: f32,
51}
52
53#[derive(Debug, Clone)]
55pub struct PromotionHistoryEntry {
56 pub rule_id: String,
58 pub source_scope: RuleScope,
60 pub target_scope: RuleScope,
62 pub promoted_at: chrono::DateTime<chrono::Utc>,
64 pub approved: bool,
66 pub reason: Option<String>,
68}
69
70pub struct RulePromoter {
72 promotion_history: Vec<PromotionHistoryEntry>,
74 pending_promotions: HashMap<String, RuleReview>,
76}
77
78impl RulePromoter {
79 pub fn new() -> Self {
81 Self {
82 promotion_history: Vec::new(),
83 pending_promotions: HashMap::new(),
84 }
85 }
86
87 pub fn request_promotion(
89 &mut self,
90 rule: Rule,
91 global_rules: &[Rule],
92 ) -> Result<RuleReview> {
93 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 let conflicts = self.detect_conflicts(&rule, global_rules)?;
105
106 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 let rule_review = RuleReview {
117 rule: rule.clone(),
118 promotion_metadata,
119 conflicts,
120 version_changes: None,
121 };
122
123 self.pending_promotions
125 .insert(rule.id.clone(), rule_review.clone());
126
127 Ok(rule_review)
128 }
129
130 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 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 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 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 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 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 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 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 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 pub fn get_pending_promotions(&self) -> Vec<RuleReview> {
233 self.pending_promotions.values().cloned().collect()
234 }
235
236 pub fn pending_promotion_count(&self) -> usize {
238 self.pending_promotions.len()
239 }
240
241 pub fn get_promotion_history(&self) -> Vec<PromotionHistoryEntry> {
243 self.promotion_history.clone()
244 }
245
246 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 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 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 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 pub fn validate_promotion(
288 &self,
289 promoted_rule: &Rule,
290 global_rules: &[Rule],
291 ) -> Result<()> {
292 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 if promoted_rule.source != RuleSource::Promoted {
301 return Err(LearningError::RulePromotionFailed(
302 "Promoted rule must have source 'Promoted'".to_string(),
303 ));
304 }
305
306 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 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 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 pub fn clear_pending_promotions(&mut self) {
374 self.pending_promotions.clear();
375 }
376
377 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 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, ¤t);
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}