Skip to main content

pr_bro/scoring/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Label-based scoring effect.
5///
6/// Maps label names to score effects. Multiple matching labels compound.
7///
8/// Example YAML:
9/// ```yaml
10/// labels:
11///   - name: "urgent"
12///     effect: "+10"
13///   - name: "wip"
14///     effect: "x0.5"
15/// ```
16#[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/// Main scoring configuration.
24///
25/// Defines how PR scores are calculated. Each factor is optional and can use
26/// either addition (`+N`) or multiplication (`xN`) operations.
27///
28/// Example YAML:
29/// ```yaml
30/// scoring:
31///   base_score: 100
32///   age: "+1 per 1h"
33///   approvals: "x2 per 1"
34///   size:
35///     exclude: ["*.lock"]
36///     buckets:
37///       - { range: "<100", effect: "x5" }
38///       - { range: ">=500", effect: "x0.5" }
39/// ```
40#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
41#[serde(deny_unknown_fields)]
42pub struct ScoringConfig {
43    /// Base score before factors are applied (default: 100.0)
44    #[serde(default)]
45    pub base_score: Option<f64>,
46
47    /// Age factor: format is "+N per <duration>" or "xN per <duration>"
48    /// Example: "+1 per 1h" adds 1 point per hour of age
49    #[serde(default)]
50    pub age: Option<String>,
51
52    /// Approval factor: effect string applied based on approval count
53    /// Format: "+N per 1", "xN per 1", "+N", or "xN"
54    /// Example: "+10 per 1" adds 10 points per approval
55    #[serde(default)]
56    pub approvals: Option<String>,
57
58    /// Size factor: bucket-based with optional file exclusions
59    #[serde(default)]
60    pub size: Option<SizeConfig>,
61
62    /// Label-based scoring effects (case-insensitive, multiple labels compound)
63    /// Example: [{ name: "urgent", effect: "+10" }]
64    #[serde(default)]
65    pub labels: Option<Vec<LabelEffect>>,
66
67    /// Previously reviewed factor: effect applied when user has reviewed PR
68    /// Example: "x0.5" to deprioritize previously-reviewed PRs
69    #[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
102/// Merge per-query scoring config with global config at field level.
103/// Per-query `Some` values override global values.
104/// Per-query `None` values fall through to global values.
105/// This allows setting only `scoring.age` in a query while preserving global size/approvals/etc.
106pub 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
127/// Merge SizeConfig with leaf-level field handling.
128/// When both global and query have SizeConfig:
129/// - exclude: per-query overrides global (or falls through if None)
130/// - buckets: per-query overrides global (or falls through if None)
131///
132/// Absent field (None) means inherit from global; explicitly set field means override.
133fn 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
147/// Merge label configs by name (case-insensitive).
148/// Query labels override global labels with same name.
149/// Global labels not in query are preserved.
150fn 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            // Add global labels first (lowercase keys for case-insensitive dedup)
160            for label in g {
161                merged.insert(label.name.to_lowercase(), label.clone());
162            }
163            // Override with query labels (query wins on collision)
164            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/// Size factor configuration.
173///
174/// Supports file exclusion patterns and size-based buckets.
175#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
176#[serde(deny_unknown_fields)]
177pub struct SizeConfig {
178    /// Glob patterns for files to exclude from size calculation
179    /// Example: ["*.lock", "package-lock.json"]
180    #[serde(default)]
181    pub exclude: Option<Vec<String>>,
182
183    /// Size buckets mapping line count ranges to effects
184    #[serde(default)]
185    pub buckets: Option<Vec<SizeBucket>>,
186}
187
188/// Size factor bucket.
189///
190/// Maps line count ranges to score effects.
191/// Range format: "<N", "<=N", ">N", ">=N", "N-M" (inclusive range)
192#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
193#[serde(deny_unknown_fields)]
194pub struct SizeBucket {
195    /// Range expression (e.g., "<100", ">=500", "100-500")
196    pub range: String,
197
198    /// Effect on score (e.g., "x5", "x0.5")
199    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    // --- Merge function tests ---
355
356    #[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        // Query only sets age — everything else should come from global
384        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)); // from global
395        assert_eq!(result.age, Some("+5 per 1h".to_string())); // from query
396        assert_eq!(result.approvals, Some("+10 per 1".to_string())); // from global
397        assert!(result.size.is_some()); // from global
398        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); // from global
403        assert_eq!(result.previously_reviewed, Some("x0.5".to_string())); // from global
404    }
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)); // query override
428        assert_eq!(result.age, Some("+5 per 1h".to_string())); // query override
429    }
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        // Query has size with new buckets but no exclude
449        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        // exclude falls through from global
467        assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
468        // buckets from query (explicitly set)
469        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        // Query has size with absent buckets (None = inherit)
492        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        // Absent buckets (None) fall through to global
507        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        // Query exclude overrides global
541        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        // Query with all fields None
549        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        // Should behave same as no query — returns global values
560        assert_eq!(result, global);
561    }
562
563    // --- Leaf-level size merge tests ---
564
565    #[test]
566    fn test_merge_size_exclude_inherits_global_buckets() {
567        // Query has only size.exclude, global has size.buckets
568        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, // absent = inherit
590            }),
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        // exclude from query
598        assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
599        // buckets inherited from global
600        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        // Query has only size.buckets, global has size.exclude
608        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, // absent = inherit
626                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        // exclude inherited from global
638        assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
639        // buckets from query
640        let buckets = size.buckets.unwrap();
641        assert_eq!(buckets.len(), 1);
642        assert_eq!(buckets[0].range, "<200");
643    }
644
645    // --- Label merge tests ---
646
647    #[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"); // query wins
678    }
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        // Use find to avoid order dependence (HashMap)
716        let foo = labels.iter().find(|l| l.name == "foo").unwrap();
717        assert_eq!(foo.effect, "+20"); // from query
718        let bar = labels.iter().find(|l| l.name == "bar").unwrap();
719        assert_eq!(bar.effect, "+10"); // preserved from global
720    }
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        // Only one entry — case-insensitive dedup
751        assert_eq!(labels.len(), 1);
752        assert_eq!(labels[0].name, "urgent"); // query case preserved
753        assert_eq!(labels[0].effect, "+20"); // query value wins
754    }
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}