1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
17#[serde(deny_unknown_fields)]
18pub struct LabelEffect {
19 pub name: String,
20 pub effect: String,
21}
22
23#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
41#[serde(deny_unknown_fields)]
42pub struct ScoringConfig {
43 #[serde(default)]
45 pub base_score: Option<f64>,
46
47 #[serde(default)]
50 pub age: Option<String>,
51
52 #[serde(default)]
56 pub approvals: Option<String>,
57
58 #[serde(default)]
60 pub size: Option<SizeConfig>,
61
62 #[serde(default)]
65 pub labels: Option<Vec<LabelEffect>>,
66
67 #[serde(default)]
70 pub previously_reviewed: Option<String>,
71}
72
73impl Default for ScoringConfig {
74 fn default() -> Self {
75 Self {
76 base_score: Some(100.0),
77 age: Some("+1 per 1h".to_string()),
78 approvals: Some("+10 per 1".to_string()),
79 size: Some(SizeConfig {
80 exclude: None,
81 buckets: Some(vec![
82 SizeBucket {
83 range: "<100".to_string(),
84 effect: "x5".to_string(),
85 },
86 SizeBucket {
87 range: "100-500".to_string(),
88 effect: "x1".to_string(),
89 },
90 SizeBucket {
91 range: ">500".to_string(),
92 effect: "x0.5".to_string(),
93 },
94 ]),
95 }),
96 labels: None,
97 previously_reviewed: None,
98 }
99 }
100}
101
102pub fn merge_scoring_configs(
107 global: &ScoringConfig,
108 query: Option<&ScoringConfig>,
109) -> ScoringConfig {
110 let Some(query) = query else {
111 return global.clone();
112 };
113
114 ScoringConfig {
115 base_score: query.base_score.or(global.base_score),
116 age: query.age.clone().or_else(|| global.age.clone()),
117 approvals: query.approvals.clone().or_else(|| global.approvals.clone()),
118 size: merge_size_configs(global.size.as_ref(), query.size.as_ref()),
119 labels: merge_label_configs(global.labels.as_ref(), query.labels.as_ref()),
120 previously_reviewed: query
121 .previously_reviewed
122 .clone()
123 .or_else(|| global.previously_reviewed.clone()),
124 }
125}
126
127fn merge_size_configs(
134 global: Option<&SizeConfig>,
135 query: Option<&SizeConfig>,
136) -> Option<SizeConfig> {
137 match (query, global) {
138 (Some(q), Some(g)) => Some(SizeConfig {
139 exclude: q.exclude.clone().or_else(|| g.exclude.clone()),
140 buckets: q.buckets.clone().or_else(|| g.buckets.clone()),
141 }),
142 (Some(q), None) => Some(q.clone()),
143 (None, g) => g.cloned(),
144 }
145}
146
147fn merge_label_configs(
151 global: Option<&Vec<LabelEffect>>,
152 query: Option<&Vec<LabelEffect>>,
153) -> Option<Vec<LabelEffect>> {
154 match (query, global) {
155 (None, g) => g.cloned(),
156 (Some(q), None) => Some(q.clone()),
157 (Some(q), Some(g)) => {
158 let mut merged: HashMap<String, LabelEffect> = HashMap::new();
159 for label in g {
161 merged.insert(label.name.to_lowercase(), label.clone());
162 }
163 for label in q {
165 merged.insert(label.name.to_lowercase(), label.clone());
166 }
167 Some(merged.into_values().collect())
168 }
169 }
170}
171
172#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
176#[serde(deny_unknown_fields)]
177pub struct SizeConfig {
178 #[serde(default)]
181 pub exclude: Option<Vec<String>>,
182
183 #[serde(default)]
185 pub buckets: Option<Vec<SizeBucket>>,
186}
187
188#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
193#[serde(deny_unknown_fields)]
194pub struct SizeBucket {
195 pub range: String,
197
198 pub effect: String,
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn test_default_scoring_config() {
208 let config = ScoringConfig::default();
209
210 assert_eq!(config.base_score, Some(100.0));
211 assert_eq!(config.age, Some("+1 per 1h".to_string()));
212 assert_eq!(config.approvals, Some("+10 per 1".to_string()));
213 assert!(config.size.is_some());
214 assert!(config.labels.is_none());
215 assert!(config.previously_reviewed.is_none());
216 }
217
218 #[test]
219 fn test_scoring_config_serde_roundtrip() {
220 let config = ScoringConfig::default();
221 let yaml = serde_saphyr::to_string(&config).unwrap();
222 let parsed: ScoringConfig = serde_saphyr::from_str(&yaml).unwrap();
223 assert_eq!(config, parsed);
224 }
225
226 #[test]
227 fn test_partial_scoring_config_parse() {
228 let yaml = r#"
229base_score: 200
230age: "+5 per 1h"
231"#;
232 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
233 assert_eq!(config.base_score, Some(200.0));
234 assert_eq!(config.age, Some("+5 per 1h".to_string()));
235 assert!(config.approvals.is_none());
236 assert!(config.size.is_none());
237 assert!(config.labels.is_none());
238 assert!(config.previously_reviewed.is_none());
239 }
240
241 #[test]
242 fn test_full_scoring_config_parse() {
243 let yaml = r#"
244base_score: 100
245age: "+1 per 1h"
246approvals: "x2 per 1"
247size:
248 exclude:
249 - "*.lock"
250 - "package-lock.json"
251 buckets:
252 - range: "<100"
253 effect: "x5"
254 - range: ">=500"
255 effect: "x0.5"
256"#;
257 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
258 assert_eq!(config.base_score, Some(100.0));
259 assert_eq!(config.age, Some("+1 per 1h".to_string()));
260 assert_eq!(config.approvals, Some("x2 per 1".to_string()));
261
262 let size = config.size.unwrap();
263 assert_eq!(size.exclude.unwrap().len(), 2);
264 assert_eq!(size.buckets.as_ref().unwrap().len(), 2);
265 }
266
267 #[test]
268 fn test_empty_scoring_config_parse() {
269 let yaml = "{}";
270 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
271 assert!(config.base_score.is_none());
272 assert!(config.age.is_none());
273 assert!(config.approvals.is_none());
274 assert!(config.size.is_none());
275 assert!(config.labels.is_none());
276 assert!(config.previously_reviewed.is_none());
277 }
278
279 #[test]
280 fn test_size_config_without_exclude() {
281 let yaml = r#"
282buckets:
283 - range: "<100"
284 effect: "x5"
285"#;
286 let config: SizeConfig = serde_saphyr::from_str(yaml).unwrap();
287 assert!(config.exclude.is_none());
288 assert_eq!(config.buckets.as_ref().unwrap().len(), 1);
289 }
290
291 #[test]
292 fn test_size_config_exclude_only() {
293 let yaml = r#"
294exclude:
295 - "*.lock"
296 - "package-lock.json"
297"#;
298 let config: SizeConfig = serde_saphyr::from_str(yaml).unwrap();
299 assert_eq!(config.exclude.as_ref().unwrap().len(), 2);
300 assert!(config.buckets.is_none());
301 }
302
303 #[test]
304 fn test_labels_config_parse() {
305 let yaml = r#"
306labels:
307 - name: "urgent"
308 effect: "+10"
309 - name: "wip"
310 effect: "x0.5"
311"#;
312 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
313 let labels = config.labels.unwrap();
314 assert_eq!(labels.len(), 2);
315 assert_eq!(labels[0].name, "urgent");
316 assert_eq!(labels[0].effect, "+10");
317 assert_eq!(labels[1].name, "wip");
318 assert_eq!(labels[1].effect, "x0.5");
319 }
320
321 #[test]
322 fn test_previously_reviewed_config_parse() {
323 let yaml = r#"
324previously_reviewed: "x0.5"
325"#;
326 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
327 assert_eq!(config.previously_reviewed, Some("x0.5".to_string()));
328 }
329
330 #[test]
331 fn test_full_config_with_all_factors() {
332 let yaml = r#"
333base_score: 100
334age: "+1 per 1h"
335approvals: "x2 per 1"
336size:
337 buckets:
338 - range: "<100"
339 effect: "x5"
340labels:
341 - name: "urgent"
342 effect: "+20"
343previously_reviewed: "x0.5"
344"#;
345 let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
346 assert_eq!(config.base_score, Some(100.0));
347 assert_eq!(config.age, Some("+1 per 1h".to_string()));
348 assert_eq!(config.approvals, Some("x2 per 1".to_string()));
349 assert!(config.size.is_some());
350 assert_eq!(config.labels.as_ref().unwrap().len(), 1);
351 assert_eq!(config.previously_reviewed, Some("x0.5".to_string()));
352 }
353
354 #[test]
357 fn test_merge_no_query_returns_global() {
358 let global = ScoringConfig::default();
359 let result = merge_scoring_configs(&global, None);
360 assert_eq!(result, global);
361 }
362
363 #[test]
364 fn test_merge_partial_query_preserves_global_fields() {
365 let global = ScoringConfig {
366 base_score: Some(100.0),
367 age: Some("+1 per 1h".to_string()),
368 approvals: Some("+10 per 1".to_string()),
369 size: Some(SizeConfig {
370 exclude: Some(vec!["*.lock".to_string()]),
371 buckets: Some(vec![SizeBucket {
372 range: "<100".to_string(),
373 effect: "x5".to_string(),
374 }]),
375 }),
376 labels: Some(vec![LabelEffect {
377 name: "urgent".to_string(),
378 effect: "+10".to_string(),
379 }]),
380 previously_reviewed: Some("x0.5".to_string()),
381 };
382
383 let query = ScoringConfig {
385 base_score: None,
386 age: Some("+5 per 1h".to_string()),
387 approvals: None,
388 size: None,
389 labels: None,
390 previously_reviewed: None,
391 };
392
393 let result = merge_scoring_configs(&global, Some(&query));
394 assert_eq!(result.base_score, Some(100.0)); assert_eq!(result.age, Some("+5 per 1h".to_string())); assert_eq!(result.approvals, Some("+10 per 1".to_string())); assert!(result.size.is_some()); assert_eq!(
399 result.size.as_ref().unwrap().exclude,
400 Some(vec!["*.lock".to_string()])
401 );
402 assert_eq!(result.labels.as_ref().unwrap().len(), 1); assert_eq!(result.previously_reviewed, Some("x0.5".to_string())); }
405
406 #[test]
407 fn test_merge_query_overrides_global() {
408 let global = ScoringConfig {
409 base_score: Some(100.0),
410 age: Some("+1 per 1h".to_string()),
411 approvals: None,
412 size: None,
413 labels: None,
414 previously_reviewed: None,
415 };
416
417 let query = ScoringConfig {
418 base_score: Some(200.0),
419 age: Some("+5 per 1h".to_string()),
420 approvals: None,
421 size: None,
422 labels: None,
423 previously_reviewed: None,
424 };
425
426 let result = merge_scoring_configs(&global, Some(&query));
427 assert_eq!(result.base_score, Some(200.0)); assert_eq!(result.age, Some("+5 per 1h".to_string())); }
430
431 #[test]
432 fn test_merge_size_config_preserves_global_exclude() {
433 let global = ScoringConfig {
434 base_score: None,
435 age: None,
436 approvals: None,
437 size: Some(SizeConfig {
438 exclude: Some(vec!["*.lock".to_string()]),
439 buckets: Some(vec![SizeBucket {
440 range: "<100".to_string(),
441 effect: "x5".to_string(),
442 }]),
443 }),
444 labels: None,
445 previously_reviewed: None,
446 };
447
448 let query = ScoringConfig {
450 base_score: None,
451 age: None,
452 approvals: None,
453 size: Some(SizeConfig {
454 exclude: None,
455 buckets: Some(vec![SizeBucket {
456 range: "<50".to_string(),
457 effect: "x10".to_string(),
458 }]),
459 }),
460 labels: None,
461 previously_reviewed: None,
462 };
463
464 let result = merge_scoring_configs(&global, Some(&query));
465 let size = result.size.unwrap();
466 assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
468 let buckets = size.buckets.unwrap();
470 assert_eq!(buckets.len(), 1);
471 assert_eq!(buckets[0].range, "<50");
472 }
473
474 #[test]
475 fn test_merge_size_config_absent_buckets_falls_through() {
476 let global = ScoringConfig {
477 base_score: None,
478 age: None,
479 approvals: None,
480 size: Some(SizeConfig {
481 exclude: None,
482 buckets: Some(vec![SizeBucket {
483 range: "<100".to_string(),
484 effect: "x5".to_string(),
485 }]),
486 }),
487 labels: None,
488 previously_reviewed: None,
489 };
490
491 let query = ScoringConfig {
493 base_score: None,
494 age: None,
495 approvals: None,
496 size: Some(SizeConfig {
497 exclude: None,
498 buckets: None,
499 }),
500 labels: None,
501 previously_reviewed: None,
502 };
503
504 let result = merge_scoring_configs(&global, Some(&query));
505 let size = result.size.unwrap();
506 let buckets = size.buckets.unwrap();
508 assert_eq!(buckets.len(), 1);
509 assert_eq!(buckets[0].range, "<100");
510 }
511
512 #[test]
513 fn test_merge_size_config_query_exclude_overrides_global() {
514 let global = ScoringConfig {
515 base_score: None,
516 age: None,
517 approvals: None,
518 size: Some(SizeConfig {
519 exclude: Some(vec!["*.lock".to_string()]),
520 buckets: None,
521 }),
522 labels: None,
523 previously_reviewed: None,
524 };
525
526 let query = ScoringConfig {
527 base_score: None,
528 age: None,
529 approvals: None,
530 size: Some(SizeConfig {
531 exclude: Some(vec!["*.json".to_string()]),
532 buckets: None,
533 }),
534 labels: None,
535 previously_reviewed: None,
536 };
537
538 let result = merge_scoring_configs(&global, Some(&query));
539 let size = result.size.unwrap();
540 assert_eq!(size.exclude, Some(vec!["*.json".to_string()]));
542 }
543
544 #[test]
545 fn test_merge_all_none_query() {
546 let global = ScoringConfig::default();
547
548 let query = ScoringConfig {
550 base_score: None,
551 age: None,
552 approvals: None,
553 size: None,
554 labels: None,
555 previously_reviewed: None,
556 };
557
558 let result = merge_scoring_configs(&global, Some(&query));
559 assert_eq!(result, global);
561 }
562
563 #[test]
566 fn test_merge_size_exclude_inherits_global_buckets() {
567 let global = ScoringConfig {
569 base_score: None,
570 age: None,
571 approvals: None,
572 size: Some(SizeConfig {
573 exclude: None,
574 buckets: Some(vec![SizeBucket {
575 range: "<100".to_string(),
576 effect: "x5".to_string(),
577 }]),
578 }),
579 labels: None,
580 previously_reviewed: None,
581 };
582
583 let query = ScoringConfig {
584 base_score: None,
585 age: None,
586 approvals: None,
587 size: Some(SizeConfig {
588 exclude: Some(vec!["*.lock".to_string()]),
589 buckets: None, }),
591 labels: None,
592 previously_reviewed: None,
593 };
594
595 let result = merge_scoring_configs(&global, Some(&query));
596 let size = result.size.unwrap();
597 assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
599 let buckets = size.buckets.unwrap();
601 assert_eq!(buckets.len(), 1);
602 assert_eq!(buckets[0].range, "<100");
603 }
604
605 #[test]
606 fn test_merge_size_buckets_inherits_global_exclude() {
607 let global = ScoringConfig {
609 base_score: None,
610 age: None,
611 approvals: None,
612 size: Some(SizeConfig {
613 exclude: Some(vec!["*.lock".to_string()]),
614 buckets: None,
615 }),
616 labels: None,
617 previously_reviewed: None,
618 };
619
620 let query = ScoringConfig {
621 base_score: None,
622 age: None,
623 approvals: None,
624 size: Some(SizeConfig {
625 exclude: None, buckets: Some(vec![SizeBucket {
627 range: "<200".to_string(),
628 effect: "x3".to_string(),
629 }]),
630 }),
631 labels: None,
632 previously_reviewed: None,
633 };
634
635 let result = merge_scoring_configs(&global, Some(&query));
636 let size = result.size.unwrap();
637 assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
639 let buckets = size.buckets.unwrap();
641 assert_eq!(buckets.len(), 1);
642 assert_eq!(buckets[0].range, "<200");
643 }
644
645 #[test]
648 fn test_merge_labels_by_name_query_wins() {
649 let global = ScoringConfig {
650 base_score: None,
651 age: None,
652 approvals: None,
653 size: None,
654 labels: Some(vec![LabelEffect {
655 name: "foo".to_string(),
656 effect: "x3".to_string(),
657 }]),
658 previously_reviewed: None,
659 };
660
661 let query = ScoringConfig {
662 base_score: None,
663 age: None,
664 approvals: None,
665 size: None,
666 labels: Some(vec![LabelEffect {
667 name: "foo".to_string(),
668 effect: "x2".to_string(),
669 }]),
670 previously_reviewed: None,
671 };
672
673 let result = merge_scoring_configs(&global, Some(&query));
674 let labels = result.labels.unwrap();
675 assert_eq!(labels.len(), 1);
676 assert_eq!(labels[0].name, "foo");
677 assert_eq!(labels[0].effect, "x2"); }
679
680 #[test]
681 fn test_merge_labels_preserves_unmentioned_global() {
682 let global = ScoringConfig {
683 base_score: None,
684 age: None,
685 approvals: None,
686 size: None,
687 labels: Some(vec![
688 LabelEffect {
689 name: "foo".to_string(),
690 effect: "+5".to_string(),
691 },
692 LabelEffect {
693 name: "bar".to_string(),
694 effect: "+10".to_string(),
695 },
696 ]),
697 previously_reviewed: None,
698 };
699
700 let query = ScoringConfig {
701 base_score: None,
702 age: None,
703 approvals: None,
704 size: None,
705 labels: Some(vec![LabelEffect {
706 name: "foo".to_string(),
707 effect: "+20".to_string(),
708 }]),
709 previously_reviewed: None,
710 };
711
712 let result = merge_scoring_configs(&global, Some(&query));
713 let labels = result.labels.unwrap();
714 assert_eq!(labels.len(), 2);
715 let foo = labels.iter().find(|l| l.name == "foo").unwrap();
717 assert_eq!(foo.effect, "+20"); let bar = labels.iter().find(|l| l.name == "bar").unwrap();
719 assert_eq!(bar.effect, "+10"); }
721
722 #[test]
723 fn test_merge_labels_case_insensitive() {
724 let global = ScoringConfig {
725 base_score: None,
726 age: None,
727 approvals: None,
728 size: None,
729 labels: Some(vec![LabelEffect {
730 name: "Urgent".to_string(),
731 effect: "+10".to_string(),
732 }]),
733 previously_reviewed: None,
734 };
735
736 let query = ScoringConfig {
737 base_score: None,
738 age: None,
739 approvals: None,
740 size: None,
741 labels: Some(vec![LabelEffect {
742 name: "urgent".to_string(),
743 effect: "+20".to_string(),
744 }]),
745 previously_reviewed: None,
746 };
747
748 let result = merge_scoring_configs(&global, Some(&query));
749 let labels = result.labels.unwrap();
750 assert_eq!(labels.len(), 1);
752 assert_eq!(labels[0].name, "urgent"); assert_eq!(labels[0].effect, "+20"); }
755
756 #[test]
757 fn test_merge_labels_no_global() {
758 let global = ScoringConfig {
759 base_score: None,
760 age: None,
761 approvals: None,
762 size: None,
763 labels: None,
764 previously_reviewed: None,
765 };
766
767 let query = ScoringConfig {
768 base_score: None,
769 age: None,
770 approvals: None,
771 size: None,
772 labels: Some(vec![LabelEffect {
773 name: "foo".to_string(),
774 effect: "+5".to_string(),
775 }]),
776 previously_reviewed: None,
777 };
778
779 let result = merge_scoring_configs(&global, Some(&query));
780 let labels = result.labels.unwrap();
781 assert_eq!(labels.len(), 1);
782 assert_eq!(labels[0].name, "foo");
783 }
784
785 #[test]
786 fn test_merge_labels_no_query() {
787 let global = ScoringConfig {
788 base_score: None,
789 age: None,
790 approvals: None,
791 size: None,
792 labels: Some(vec![LabelEffect {
793 name: "bar".to_string(),
794 effect: "+10".to_string(),
795 }]),
796 previously_reviewed: None,
797 };
798
799 let query = ScoringConfig {
800 base_score: None,
801 age: None,
802 approvals: None,
803 size: None,
804 labels: None,
805 previously_reviewed: None,
806 };
807
808 let result = merge_scoring_configs(&global, Some(&query));
809 let labels = result.labels.unwrap();
810 assert_eq!(labels.len(), 1);
811 assert_eq!(labels[0].name, "bar");
812 }
813}