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    /// Draft factor: effect applied when PR is a draft
73    /// Example: "x0.1" to deprioritize draft PRs
74    #[serde(default)]
75    pub draft: Option<String>,
76}
77
78impl Default for ScoringConfig {
79    fn default() -> Self {
80        Self {
81            base_score: Some(100.0),
82            age: Some("+1 per 1h".to_string()),
83            approvals: Some("+10 per 1".to_string()),
84            size: Some(SizeConfig {
85                exclude: None,
86                buckets: Some(vec![
87                    SizeBucket {
88                        range: "<100".to_string(),
89                        effect: "x5".to_string(),
90                    },
91                    SizeBucket {
92                        range: "100-500".to_string(),
93                        effect: "x1".to_string(),
94                    },
95                    SizeBucket {
96                        range: ">500".to_string(),
97                        effect: "x0.5".to_string(),
98                    },
99                ]),
100            }),
101            labels: None,
102            previously_reviewed: None,
103            draft: None,
104        }
105    }
106}
107
108/// Merge per-query scoring config with global config at field level.
109/// Per-query `Some` values override global values.
110/// Per-query `None` values fall through to global values.
111/// This allows setting only `scoring.age` in a query while preserving global size/approvals/etc.
112pub fn merge_scoring_configs(
113    global: &ScoringConfig,
114    query: Option<&ScoringConfig>,
115) -> ScoringConfig {
116    let Some(query) = query else {
117        return global.clone();
118    };
119
120    ScoringConfig {
121        base_score: query.base_score.or(global.base_score),
122        age: query.age.clone().or_else(|| global.age.clone()),
123        approvals: query.approvals.clone().or_else(|| global.approvals.clone()),
124        size: merge_size_configs(global.size.as_ref(), query.size.as_ref()),
125        labels: merge_label_configs(global.labels.as_ref(), query.labels.as_ref()),
126        previously_reviewed: query
127            .previously_reviewed
128            .clone()
129            .or_else(|| global.previously_reviewed.clone()),
130        draft: query.draft.clone().or_else(|| global.draft.clone()),
131    }
132}
133
134/// Merge SizeConfig with leaf-level field handling.
135/// When both global and query have SizeConfig:
136/// - exclude: per-query overrides global (or falls through if None)
137/// - buckets: per-query overrides global (or falls through if None)
138///
139/// Absent field (None) means inherit from global; explicitly set field means override.
140fn merge_size_configs(
141    global: Option<&SizeConfig>,
142    query: Option<&SizeConfig>,
143) -> Option<SizeConfig> {
144    match (query, global) {
145        (Some(q), Some(g)) => Some(SizeConfig {
146            exclude: q.exclude.clone().or_else(|| g.exclude.clone()),
147            buckets: q.buckets.clone().or_else(|| g.buckets.clone()),
148        }),
149        (Some(q), None) => Some(q.clone()),
150        (None, g) => g.cloned(),
151    }
152}
153
154/// Merge label configs by name (case-insensitive).
155/// Query labels override global labels with same name.
156/// Global labels not in query are preserved.
157fn merge_label_configs(
158    global: Option<&Vec<LabelEffect>>,
159    query: Option<&Vec<LabelEffect>>,
160) -> Option<Vec<LabelEffect>> {
161    match (query, global) {
162        (None, g) => g.cloned(),
163        (Some(q), None) => Some(q.clone()),
164        (Some(q), Some(g)) => {
165            let mut merged: HashMap<String, LabelEffect> = HashMap::new();
166            // Add global labels first (lowercase keys for case-insensitive dedup)
167            for label in g {
168                merged.insert(label.name.to_lowercase(), label.clone());
169            }
170            // Override with query labels (query wins on collision)
171            for label in q {
172                merged.insert(label.name.to_lowercase(), label.clone());
173            }
174            Some(merged.into_values().collect())
175        }
176    }
177}
178
179/// Size factor configuration.
180///
181/// Supports file exclusion patterns and size-based buckets.
182#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
183#[serde(deny_unknown_fields)]
184pub struct SizeConfig {
185    /// Glob patterns for files to exclude from size calculation
186    /// Example: ["*.lock", "package-lock.json"]
187    #[serde(default)]
188    pub exclude: Option<Vec<String>>,
189
190    /// Size buckets mapping line count ranges to effects
191    #[serde(default)]
192    pub buckets: Option<Vec<SizeBucket>>,
193}
194
195/// Size factor bucket.
196///
197/// Maps line count ranges to score effects.
198/// Range format: "<N", "<=N", ">N", ">=N", "N-M" (inclusive range)
199#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
200#[serde(deny_unknown_fields)]
201pub struct SizeBucket {
202    /// Range expression (e.g., "<100", ">=500", "100-500")
203    pub range: String,
204
205    /// Effect on score (e.g., "x5", "x0.5")
206    pub effect: String,
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_default_scoring_config() {
215        let config = ScoringConfig::default();
216
217        assert_eq!(config.base_score, Some(100.0));
218        assert_eq!(config.age, Some("+1 per 1h".to_string()));
219        assert_eq!(config.approvals, Some("+10 per 1".to_string()));
220        assert!(config.size.is_some());
221        assert!(config.labels.is_none());
222        assert!(config.previously_reviewed.is_none());
223    }
224
225    #[test]
226    fn test_scoring_config_serde_roundtrip() {
227        let config = ScoringConfig::default();
228        let yaml = serde_saphyr::to_string(&config).unwrap();
229        let parsed: ScoringConfig = serde_saphyr::from_str(&yaml).unwrap();
230        assert_eq!(config, parsed);
231    }
232
233    #[test]
234    fn test_partial_scoring_config_parse() {
235        let yaml = r#"
236base_score: 200
237age: "+5 per 1h"
238"#;
239        let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
240        assert_eq!(config.base_score, Some(200.0));
241        assert_eq!(config.age, Some("+5 per 1h".to_string()));
242        assert!(config.approvals.is_none());
243        assert!(config.size.is_none());
244        assert!(config.labels.is_none());
245        assert!(config.previously_reviewed.is_none());
246    }
247
248    #[test]
249    fn test_full_scoring_config_parse() {
250        let yaml = r#"
251base_score: 100
252age: "+1 per 1h"
253approvals: "x2 per 1"
254size:
255  exclude:
256    - "*.lock"
257    - "package-lock.json"
258  buckets:
259    - range: "<100"
260      effect: "x5"
261    - range: ">=500"
262      effect: "x0.5"
263"#;
264        let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
265        assert_eq!(config.base_score, Some(100.0));
266        assert_eq!(config.age, Some("+1 per 1h".to_string()));
267        assert_eq!(config.approvals, Some("x2 per 1".to_string()));
268
269        let size = config.size.unwrap();
270        assert_eq!(size.exclude.unwrap().len(), 2);
271        assert_eq!(size.buckets.as_ref().unwrap().len(), 2);
272    }
273
274    #[test]
275    fn test_empty_scoring_config_parse() {
276        let yaml = "{}";
277        let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
278        assert!(config.base_score.is_none());
279        assert!(config.age.is_none());
280        assert!(config.approvals.is_none());
281        assert!(config.size.is_none());
282        assert!(config.labels.is_none());
283        assert!(config.previously_reviewed.is_none());
284    }
285
286    #[test]
287    fn test_size_config_without_exclude() {
288        let yaml = r#"
289buckets:
290  - range: "<100"
291    effect: "x5"
292"#;
293        let config: SizeConfig = serde_saphyr::from_str(yaml).unwrap();
294        assert!(config.exclude.is_none());
295        assert_eq!(config.buckets.as_ref().unwrap().len(), 1);
296    }
297
298    #[test]
299    fn test_size_config_exclude_only() {
300        let yaml = r#"
301exclude:
302  - "*.lock"
303  - "package-lock.json"
304"#;
305        let config: SizeConfig = serde_saphyr::from_str(yaml).unwrap();
306        assert_eq!(config.exclude.as_ref().unwrap().len(), 2);
307        assert!(config.buckets.is_none());
308    }
309
310    #[test]
311    fn test_labels_config_parse() {
312        let yaml = r#"
313labels:
314  - name: "urgent"
315    effect: "+10"
316  - name: "wip"
317    effect: "x0.5"
318"#;
319        let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
320        let labels = config.labels.unwrap();
321        assert_eq!(labels.len(), 2);
322        assert_eq!(labels[0].name, "urgent");
323        assert_eq!(labels[0].effect, "+10");
324        assert_eq!(labels[1].name, "wip");
325        assert_eq!(labels[1].effect, "x0.5");
326    }
327
328    #[test]
329    fn test_previously_reviewed_config_parse() {
330        let yaml = r#"
331previously_reviewed: "x0.5"
332"#;
333        let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
334        assert_eq!(config.previously_reviewed, Some("x0.5".to_string()));
335    }
336
337    #[test]
338    fn test_draft_config_parse() {
339        let yaml = r#"
340draft: "x0.1"
341"#;
342        let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
343        assert_eq!(config.draft, Some("x0.1".to_string()));
344    }
345
346    #[test]
347    fn test_full_config_with_all_factors() {
348        let yaml = r#"
349base_score: 100
350age: "+1 per 1h"
351approvals: "x2 per 1"
352size:
353  buckets:
354    - range: "<100"
355      effect: "x5"
356labels:
357  - name: "urgent"
358    effect: "+20"
359previously_reviewed: "x0.5"
360draft: "x0.1"
361"#;
362        let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
363        assert_eq!(config.base_score, Some(100.0));
364        assert_eq!(config.age, Some("+1 per 1h".to_string()));
365        assert_eq!(config.approvals, Some("x2 per 1".to_string()));
366        assert!(config.size.is_some());
367        assert_eq!(config.labels.as_ref().unwrap().len(), 1);
368        assert_eq!(config.previously_reviewed, Some("x0.5".to_string()));
369        assert_eq!(config.draft, Some("x0.1".to_string()));
370    }
371
372    // --- Merge function tests ---
373
374    #[test]
375    fn test_merge_no_query_returns_global() {
376        let global = ScoringConfig::default();
377        let result = merge_scoring_configs(&global, None);
378        assert_eq!(result, global);
379    }
380
381    #[test]
382    fn test_merge_partial_query_preserves_global_fields() {
383        let global = ScoringConfig {
384            base_score: Some(100.0),
385            age: Some("+1 per 1h".to_string()),
386            approvals: Some("+10 per 1".to_string()),
387            size: Some(SizeConfig {
388                exclude: Some(vec!["*.lock".to_string()]),
389                buckets: Some(vec![SizeBucket {
390                    range: "<100".to_string(),
391                    effect: "x5".to_string(),
392                }]),
393            }),
394            labels: Some(vec![LabelEffect {
395                name: "urgent".to_string(),
396                effect: "+10".to_string(),
397            }]),
398            previously_reviewed: Some("x0.5".to_string()),
399            draft: None,
400        };
401
402        // Query only sets age — everything else should come from global
403        let query = ScoringConfig {
404            base_score: None,
405            age: Some("+5 per 1h".to_string()),
406            approvals: None,
407            size: None,
408            labels: None,
409            previously_reviewed: None,
410            draft: None,
411        };
412
413        let result = merge_scoring_configs(&global, Some(&query));
414        assert_eq!(result.base_score, Some(100.0)); // from global
415        assert_eq!(result.age, Some("+5 per 1h".to_string())); // from query
416        assert_eq!(result.approvals, Some("+10 per 1".to_string())); // from global
417        assert!(result.size.is_some()); // from global
418        assert_eq!(
419            result.size.as_ref().unwrap().exclude,
420            Some(vec!["*.lock".to_string()])
421        );
422        assert_eq!(result.labels.as_ref().unwrap().len(), 1); // from global
423        assert_eq!(result.previously_reviewed, Some("x0.5".to_string())); // from global
424    }
425
426    #[test]
427    fn test_merge_query_overrides_global() {
428        let global = ScoringConfig {
429            base_score: Some(100.0),
430            age: Some("+1 per 1h".to_string()),
431            approvals: None,
432            size: None,
433            labels: None,
434            previously_reviewed: None,
435            draft: None,
436        };
437
438        let query = ScoringConfig {
439            base_score: Some(200.0),
440            age: Some("+5 per 1h".to_string()),
441            approvals: None,
442            size: None,
443            labels: None,
444            previously_reviewed: None,
445            draft: None,
446        };
447
448        let result = merge_scoring_configs(&global, Some(&query));
449        assert_eq!(result.base_score, Some(200.0)); // query override
450        assert_eq!(result.age, Some("+5 per 1h".to_string())); // query override
451    }
452
453    #[test]
454    fn test_merge_size_config_preserves_global_exclude() {
455        let global = ScoringConfig {
456            base_score: None,
457            age: None,
458            approvals: None,
459            size: Some(SizeConfig {
460                exclude: Some(vec!["*.lock".to_string()]),
461                buckets: Some(vec![SizeBucket {
462                    range: "<100".to_string(),
463                    effect: "x5".to_string(),
464                }]),
465            }),
466            labels: None,
467            previously_reviewed: None,
468            draft: None,
469        };
470
471        // Query has size with new buckets but no exclude
472        let query = ScoringConfig {
473            base_score: None,
474            age: None,
475            approvals: None,
476            size: Some(SizeConfig {
477                exclude: None,
478                buckets: Some(vec![SizeBucket {
479                    range: "<50".to_string(),
480                    effect: "x10".to_string(),
481                }]),
482            }),
483            labels: None,
484            previously_reviewed: None,
485            draft: None,
486        };
487
488        let result = merge_scoring_configs(&global, Some(&query));
489        let size = result.size.unwrap();
490        // exclude falls through from global
491        assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
492        // buckets from query (explicitly set)
493        let buckets = size.buckets.unwrap();
494        assert_eq!(buckets.len(), 1);
495        assert_eq!(buckets[0].range, "<50");
496    }
497
498    #[test]
499    fn test_merge_size_config_absent_buckets_falls_through() {
500        let global = ScoringConfig {
501            base_score: None,
502            age: None,
503            approvals: None,
504            size: Some(SizeConfig {
505                exclude: None,
506                buckets: Some(vec![SizeBucket {
507                    range: "<100".to_string(),
508                    effect: "x5".to_string(),
509                }]),
510            }),
511            labels: None,
512            previously_reviewed: None,
513            draft: None,
514        };
515
516        // Query has size with absent buckets (None = inherit)
517        let query = ScoringConfig {
518            base_score: None,
519            age: None,
520            approvals: None,
521            size: Some(SizeConfig {
522                exclude: None,
523                buckets: None,
524            }),
525            labels: None,
526            previously_reviewed: None,
527            draft: None,
528        };
529
530        let result = merge_scoring_configs(&global, Some(&query));
531        let size = result.size.unwrap();
532        // Absent buckets (None) fall through to global
533        let buckets = size.buckets.unwrap();
534        assert_eq!(buckets.len(), 1);
535        assert_eq!(buckets[0].range, "<100");
536    }
537
538    #[test]
539    fn test_merge_size_config_query_exclude_overrides_global() {
540        let global = ScoringConfig {
541            base_score: None,
542            age: None,
543            approvals: None,
544            size: Some(SizeConfig {
545                exclude: Some(vec!["*.lock".to_string()]),
546                buckets: None,
547            }),
548            labels: None,
549            previously_reviewed: None,
550            draft: None,
551        };
552
553        let query = ScoringConfig {
554            base_score: None,
555            age: None,
556            approvals: None,
557            size: Some(SizeConfig {
558                exclude: Some(vec!["*.json".to_string()]),
559                buckets: None,
560            }),
561            labels: None,
562            previously_reviewed: None,
563            draft: None,
564        };
565
566        let result = merge_scoring_configs(&global, Some(&query));
567        let size = result.size.unwrap();
568        // Query exclude overrides global
569        assert_eq!(size.exclude, Some(vec!["*.json".to_string()]));
570    }
571
572    #[test]
573    fn test_merge_all_none_query() {
574        let global = ScoringConfig::default();
575
576        // Query with all fields None
577        let query = ScoringConfig {
578            base_score: None,
579            age: None,
580            approvals: None,
581            size: None,
582            labels: None,
583            previously_reviewed: None,
584            draft: None,
585        };
586
587        let result = merge_scoring_configs(&global, Some(&query));
588        // Should behave same as no query — returns global values
589        assert_eq!(result, global);
590    }
591
592    // --- Leaf-level size merge tests ---
593
594    #[test]
595    fn test_merge_size_exclude_inherits_global_buckets() {
596        // Query has only size.exclude, global has size.buckets
597        let global = ScoringConfig {
598            base_score: None,
599            age: None,
600            approvals: None,
601            size: Some(SizeConfig {
602                exclude: None,
603                buckets: Some(vec![SizeBucket {
604                    range: "<100".to_string(),
605                    effect: "x5".to_string(),
606                }]),
607            }),
608            labels: None,
609            previously_reviewed: None,
610            draft: None,
611        };
612
613        let query = ScoringConfig {
614            base_score: None,
615            age: None,
616            approvals: None,
617            size: Some(SizeConfig {
618                exclude: Some(vec!["*.lock".to_string()]),
619                buckets: None, // absent = inherit
620            }),
621            labels: None,
622            previously_reviewed: None,
623            draft: None,
624        };
625
626        let result = merge_scoring_configs(&global, Some(&query));
627        let size = result.size.unwrap();
628        // exclude from query
629        assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
630        // buckets inherited from global
631        let buckets = size.buckets.unwrap();
632        assert_eq!(buckets.len(), 1);
633        assert_eq!(buckets[0].range, "<100");
634    }
635
636    #[test]
637    fn test_merge_size_buckets_inherits_global_exclude() {
638        // Query has only size.buckets, global has size.exclude
639        let global = ScoringConfig {
640            base_score: None,
641            age: None,
642            approvals: None,
643            size: Some(SizeConfig {
644                exclude: Some(vec!["*.lock".to_string()]),
645                buckets: None,
646            }),
647            labels: None,
648            previously_reviewed: None,
649            draft: None,
650        };
651
652        let query = ScoringConfig {
653            base_score: None,
654            age: None,
655            approvals: None,
656            size: Some(SizeConfig {
657                exclude: None, // absent = inherit
658                buckets: Some(vec![SizeBucket {
659                    range: "<200".to_string(),
660                    effect: "x3".to_string(),
661                }]),
662            }),
663            labels: None,
664            previously_reviewed: None,
665            draft: None,
666        };
667
668        let result = merge_scoring_configs(&global, Some(&query));
669        let size = result.size.unwrap();
670        // exclude inherited from global
671        assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
672        // buckets from query
673        let buckets = size.buckets.unwrap();
674        assert_eq!(buckets.len(), 1);
675        assert_eq!(buckets[0].range, "<200");
676    }
677
678    // --- Label merge tests ---
679
680    #[test]
681    fn test_merge_labels_by_name_query_wins() {
682        let global = ScoringConfig {
683            base_score: None,
684            age: None,
685            approvals: None,
686            size: None,
687            labels: Some(vec![LabelEffect {
688                name: "foo".to_string(),
689                effect: "x3".to_string(),
690            }]),
691            previously_reviewed: None,
692            draft: None,
693        };
694
695        let query = ScoringConfig {
696            base_score: None,
697            age: None,
698            approvals: None,
699            size: None,
700            labels: Some(vec![LabelEffect {
701                name: "foo".to_string(),
702                effect: "x2".to_string(),
703            }]),
704            previously_reviewed: None,
705            draft: None,
706        };
707
708        let result = merge_scoring_configs(&global, Some(&query));
709        let labels = result.labels.unwrap();
710        assert_eq!(labels.len(), 1);
711        assert_eq!(labels[0].name, "foo");
712        assert_eq!(labels[0].effect, "x2"); // query wins
713    }
714
715    #[test]
716    fn test_merge_labels_preserves_unmentioned_global() {
717        let global = ScoringConfig {
718            base_score: None,
719            age: None,
720            approvals: None,
721            size: None,
722            labels: Some(vec![
723                LabelEffect {
724                    name: "foo".to_string(),
725                    effect: "+5".to_string(),
726                },
727                LabelEffect {
728                    name: "bar".to_string(),
729                    effect: "+10".to_string(),
730                },
731            ]),
732            previously_reviewed: None,
733            draft: 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: "foo".to_string(),
743                effect: "+20".to_string(),
744            }]),
745            previously_reviewed: None,
746            draft: None,
747        };
748
749        let result = merge_scoring_configs(&global, Some(&query));
750        let labels = result.labels.unwrap();
751        assert_eq!(labels.len(), 2);
752        // Use find to avoid order dependence (HashMap)
753        let foo = labels.iter().find(|l| l.name == "foo").unwrap();
754        assert_eq!(foo.effect, "+20"); // from query
755        let bar = labels.iter().find(|l| l.name == "bar").unwrap();
756        assert_eq!(bar.effect, "+10"); // preserved from global
757    }
758
759    #[test]
760    fn test_merge_labels_case_insensitive() {
761        let global = ScoringConfig {
762            base_score: None,
763            age: None,
764            approvals: None,
765            size: None,
766            labels: Some(vec![LabelEffect {
767                name: "Urgent".to_string(),
768                effect: "+10".to_string(),
769            }]),
770            previously_reviewed: None,
771            draft: None,
772        };
773
774        let query = ScoringConfig {
775            base_score: None,
776            age: None,
777            approvals: None,
778            size: None,
779            labels: Some(vec![LabelEffect {
780                name: "urgent".to_string(),
781                effect: "+20".to_string(),
782            }]),
783            previously_reviewed: None,
784            draft: None,
785        };
786
787        let result = merge_scoring_configs(&global, Some(&query));
788        let labels = result.labels.unwrap();
789        // Only one entry — case-insensitive dedup
790        assert_eq!(labels.len(), 1);
791        assert_eq!(labels[0].name, "urgent"); // query case preserved
792        assert_eq!(labels[0].effect, "+20"); // query value wins
793    }
794
795    #[test]
796    fn test_merge_labels_no_global() {
797        let global = ScoringConfig {
798            base_score: None,
799            age: None,
800            approvals: None,
801            size: None,
802            labels: None,
803            previously_reviewed: None,
804            draft: None,
805        };
806
807        let query = ScoringConfig {
808            base_score: None,
809            age: None,
810            approvals: None,
811            size: None,
812            labels: Some(vec![LabelEffect {
813                name: "foo".to_string(),
814                effect: "+5".to_string(),
815            }]),
816            previously_reviewed: None,
817            draft: None,
818        };
819
820        let result = merge_scoring_configs(&global, Some(&query));
821        let labels = result.labels.unwrap();
822        assert_eq!(labels.len(), 1);
823        assert_eq!(labels[0].name, "foo");
824    }
825
826    #[test]
827    fn test_merge_labels_no_query() {
828        let global = ScoringConfig {
829            base_score: None,
830            age: None,
831            approvals: None,
832            size: None,
833            labels: Some(vec![LabelEffect {
834                name: "bar".to_string(),
835                effect: "+10".to_string(),
836            }]),
837            previously_reviewed: None,
838            draft: None,
839        };
840
841        let query = ScoringConfig {
842            base_score: None,
843            age: None,
844            approvals: None,
845            size: None,
846            labels: None,
847            previously_reviewed: None,
848            draft: None,
849        };
850
851        let result = merge_scoring_configs(&global, Some(&query));
852        let labels = result.labels.unwrap();
853        assert_eq!(labels.len(), 1);
854        assert_eq!(labels[0].name, "bar");
855    }
856}