use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct LabelEffect {
pub name: String,
pub effect: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct ScoringConfig {
#[serde(default)]
pub base_score: Option<f64>,
#[serde(default)]
pub age: Option<String>,
#[serde(default)]
pub approvals: Option<String>,
#[serde(default)]
pub size: Option<SizeConfig>,
#[serde(default)]
pub labels: Option<Vec<LabelEffect>>,
#[serde(default)]
pub previously_reviewed: Option<String>,
#[serde(default)]
pub draft: Option<String>,
}
impl Default for ScoringConfig {
fn default() -> Self {
Self {
base_score: Some(100.0),
age: Some("+1 per 1h".to_string()),
approvals: Some("+10 per 1".to_string()),
size: Some(SizeConfig {
exclude: None,
buckets: Some(vec![
SizeBucket {
range: "<100".to_string(),
effect: "x5".to_string(),
},
SizeBucket {
range: "100-500".to_string(),
effect: "x1".to_string(),
},
SizeBucket {
range: ">500".to_string(),
effect: "x0.5".to_string(),
},
]),
}),
labels: None,
previously_reviewed: None,
draft: None,
}
}
}
pub fn merge_scoring_configs(
global: &ScoringConfig,
query: Option<&ScoringConfig>,
) -> ScoringConfig {
let Some(query) = query else {
return global.clone();
};
ScoringConfig {
base_score: query.base_score.or(global.base_score),
age: query.age.clone().or_else(|| global.age.clone()),
approvals: query.approvals.clone().or_else(|| global.approvals.clone()),
size: merge_size_configs(global.size.as_ref(), query.size.as_ref()),
labels: merge_label_configs(global.labels.as_ref(), query.labels.as_ref()),
previously_reviewed: query
.previously_reviewed
.clone()
.or_else(|| global.previously_reviewed.clone()),
draft: query.draft.clone().or_else(|| global.draft.clone()),
}
}
fn merge_size_configs(
global: Option<&SizeConfig>,
query: Option<&SizeConfig>,
) -> Option<SizeConfig> {
match (query, global) {
(Some(q), Some(g)) => Some(SizeConfig {
exclude: q.exclude.clone().or_else(|| g.exclude.clone()),
buckets: q.buckets.clone().or_else(|| g.buckets.clone()),
}),
(Some(q), None) => Some(q.clone()),
(None, g) => g.cloned(),
}
}
fn merge_label_configs(
global: Option<&Vec<LabelEffect>>,
query: Option<&Vec<LabelEffect>>,
) -> Option<Vec<LabelEffect>> {
match (query, global) {
(None, g) => g.cloned(),
(Some(q), None) => Some(q.clone()),
(Some(q), Some(g)) => {
let mut merged: HashMap<String, LabelEffect> = HashMap::new();
for label in g {
merged.insert(label.name.to_lowercase(), label.clone());
}
for label in q {
merged.insert(label.name.to_lowercase(), label.clone());
}
Some(merged.into_values().collect())
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct SizeConfig {
#[serde(default)]
pub exclude: Option<Vec<String>>,
#[serde(default)]
pub buckets: Option<Vec<SizeBucket>>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct SizeBucket {
pub range: String,
pub effect: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_scoring_config() {
let config = ScoringConfig::default();
assert_eq!(config.base_score, Some(100.0));
assert_eq!(config.age, Some("+1 per 1h".to_string()));
assert_eq!(config.approvals, Some("+10 per 1".to_string()));
assert!(config.size.is_some());
assert!(config.labels.is_none());
assert!(config.previously_reviewed.is_none());
}
#[test]
fn test_scoring_config_serde_roundtrip() {
let config = ScoringConfig::default();
let yaml = serde_saphyr::to_string(&config).unwrap();
let parsed: ScoringConfig = serde_saphyr::from_str(&yaml).unwrap();
assert_eq!(config, parsed);
}
#[test]
fn test_partial_scoring_config_parse() {
let yaml = r#"
base_score: 200
age: "+5 per 1h"
"#;
let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(config.base_score, Some(200.0));
assert_eq!(config.age, Some("+5 per 1h".to_string()));
assert!(config.approvals.is_none());
assert!(config.size.is_none());
assert!(config.labels.is_none());
assert!(config.previously_reviewed.is_none());
}
#[test]
fn test_full_scoring_config_parse() {
let yaml = r#"
base_score: 100
age: "+1 per 1h"
approvals: "x2 per 1"
size:
exclude:
- "*.lock"
- "package-lock.json"
buckets:
- range: "<100"
effect: "x5"
- range: ">=500"
effect: "x0.5"
"#;
let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(config.base_score, Some(100.0));
assert_eq!(config.age, Some("+1 per 1h".to_string()));
assert_eq!(config.approvals, Some("x2 per 1".to_string()));
let size = config.size.unwrap();
assert_eq!(size.exclude.unwrap().len(), 2);
assert_eq!(size.buckets.as_ref().unwrap().len(), 2);
}
#[test]
fn test_empty_scoring_config_parse() {
let yaml = "{}";
let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
assert!(config.base_score.is_none());
assert!(config.age.is_none());
assert!(config.approvals.is_none());
assert!(config.size.is_none());
assert!(config.labels.is_none());
assert!(config.previously_reviewed.is_none());
}
#[test]
fn test_size_config_without_exclude() {
let yaml = r#"
buckets:
- range: "<100"
effect: "x5"
"#;
let config: SizeConfig = serde_saphyr::from_str(yaml).unwrap();
assert!(config.exclude.is_none());
assert_eq!(config.buckets.as_ref().unwrap().len(), 1);
}
#[test]
fn test_size_config_exclude_only() {
let yaml = r#"
exclude:
- "*.lock"
- "package-lock.json"
"#;
let config: SizeConfig = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(config.exclude.as_ref().unwrap().len(), 2);
assert!(config.buckets.is_none());
}
#[test]
fn test_labels_config_parse() {
let yaml = r#"
labels:
- name: "urgent"
effect: "+10"
- name: "wip"
effect: "x0.5"
"#;
let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
let labels = config.labels.unwrap();
assert_eq!(labels.len(), 2);
assert_eq!(labels[0].name, "urgent");
assert_eq!(labels[0].effect, "+10");
assert_eq!(labels[1].name, "wip");
assert_eq!(labels[1].effect, "x0.5");
}
#[test]
fn test_previously_reviewed_config_parse() {
let yaml = r#"
previously_reviewed: "x0.5"
"#;
let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(config.previously_reviewed, Some("x0.5".to_string()));
}
#[test]
fn test_draft_config_parse() {
let yaml = r#"
draft: "x0.1"
"#;
let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(config.draft, Some("x0.1".to_string()));
}
#[test]
fn test_full_config_with_all_factors() {
let yaml = r#"
base_score: 100
age: "+1 per 1h"
approvals: "x2 per 1"
size:
buckets:
- range: "<100"
effect: "x5"
labels:
- name: "urgent"
effect: "+20"
previously_reviewed: "x0.5"
draft: "x0.1"
"#;
let config: ScoringConfig = serde_saphyr::from_str(yaml).unwrap();
assert_eq!(config.base_score, Some(100.0));
assert_eq!(config.age, Some("+1 per 1h".to_string()));
assert_eq!(config.approvals, Some("x2 per 1".to_string()));
assert!(config.size.is_some());
assert_eq!(config.labels.as_ref().unwrap().len(), 1);
assert_eq!(config.previously_reviewed, Some("x0.5".to_string()));
assert_eq!(config.draft, Some("x0.1".to_string()));
}
#[test]
fn test_merge_no_query_returns_global() {
let global = ScoringConfig::default();
let result = merge_scoring_configs(&global, None);
assert_eq!(result, global);
}
#[test]
fn test_merge_partial_query_preserves_global_fields() {
let global = ScoringConfig {
base_score: Some(100.0),
age: Some("+1 per 1h".to_string()),
approvals: Some("+10 per 1".to_string()),
size: Some(SizeConfig {
exclude: Some(vec!["*.lock".to_string()]),
buckets: Some(vec![SizeBucket {
range: "<100".to_string(),
effect: "x5".to_string(),
}]),
}),
labels: Some(vec![LabelEffect {
name: "urgent".to_string(),
effect: "+10".to_string(),
}]),
previously_reviewed: Some("x0.5".to_string()),
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: Some("+5 per 1h".to_string()),
approvals: None,
size: None,
labels: None,
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
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!(
result.size.as_ref().unwrap().exclude,
Some(vec!["*.lock".to_string()])
);
assert_eq!(result.labels.as_ref().unwrap().len(), 1); assert_eq!(result.previously_reviewed, Some("x0.5".to_string())); }
#[test]
fn test_merge_query_overrides_global() {
let global = ScoringConfig {
base_score: Some(100.0),
age: Some("+1 per 1h".to_string()),
approvals: None,
size: None,
labels: None,
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: Some(200.0),
age: Some("+5 per 1h".to_string()),
approvals: None,
size: None,
labels: None,
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
assert_eq!(result.base_score, Some(200.0)); assert_eq!(result.age, Some("+5 per 1h".to_string())); }
#[test]
fn test_merge_size_config_preserves_global_exclude() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: Some(vec!["*.lock".to_string()]),
buckets: Some(vec![SizeBucket {
range: "<100".to_string(),
effect: "x5".to_string(),
}]),
}),
labels: None,
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: None,
buckets: Some(vec![SizeBucket {
range: "<50".to_string(),
effect: "x10".to_string(),
}]),
}),
labels: None,
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let size = result.size.unwrap();
assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
let buckets = size.buckets.unwrap();
assert_eq!(buckets.len(), 1);
assert_eq!(buckets[0].range, "<50");
}
#[test]
fn test_merge_size_config_absent_buckets_falls_through() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: None,
buckets: Some(vec![SizeBucket {
range: "<100".to_string(),
effect: "x5".to_string(),
}]),
}),
labels: None,
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: None,
buckets: None,
}),
labels: None,
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let size = result.size.unwrap();
let buckets = size.buckets.unwrap();
assert_eq!(buckets.len(), 1);
assert_eq!(buckets[0].range, "<100");
}
#[test]
fn test_merge_size_config_query_exclude_overrides_global() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: Some(vec!["*.lock".to_string()]),
buckets: None,
}),
labels: None,
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: Some(vec!["*.json".to_string()]),
buckets: None,
}),
labels: None,
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let size = result.size.unwrap();
assert_eq!(size.exclude, Some(vec!["*.json".to_string()]));
}
#[test]
fn test_merge_all_none_query() {
let global = ScoringConfig::default();
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: None,
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
assert_eq!(result, global);
}
#[test]
fn test_merge_size_exclude_inherits_global_buckets() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: None,
buckets: Some(vec![SizeBucket {
range: "<100".to_string(),
effect: "x5".to_string(),
}]),
}),
labels: None,
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: Some(vec!["*.lock".to_string()]),
buckets: None, }),
labels: None,
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let size = result.size.unwrap();
assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
let buckets = size.buckets.unwrap();
assert_eq!(buckets.len(), 1);
assert_eq!(buckets[0].range, "<100");
}
#[test]
fn test_merge_size_buckets_inherits_global_exclude() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: Some(vec!["*.lock".to_string()]),
buckets: None,
}),
labels: None,
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: Some(SizeConfig {
exclude: None, buckets: Some(vec![SizeBucket {
range: "<200".to_string(),
effect: "x3".to_string(),
}]),
}),
labels: None,
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let size = result.size.unwrap();
assert_eq!(size.exclude, Some(vec!["*.lock".to_string()]));
let buckets = size.buckets.unwrap();
assert_eq!(buckets.len(), 1);
assert_eq!(buckets[0].range, "<200");
}
#[test]
fn test_merge_labels_by_name_query_wins() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: Some(vec![LabelEffect {
name: "foo".to_string(),
effect: "x3".to_string(),
}]),
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: Some(vec![LabelEffect {
name: "foo".to_string(),
effect: "x2".to_string(),
}]),
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let labels = result.labels.unwrap();
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].name, "foo");
assert_eq!(labels[0].effect, "x2"); }
#[test]
fn test_merge_labels_preserves_unmentioned_global() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: Some(vec![
LabelEffect {
name: "foo".to_string(),
effect: "+5".to_string(),
},
LabelEffect {
name: "bar".to_string(),
effect: "+10".to_string(),
},
]),
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: Some(vec![LabelEffect {
name: "foo".to_string(),
effect: "+20".to_string(),
}]),
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let labels = result.labels.unwrap();
assert_eq!(labels.len(), 2);
let foo = labels.iter().find(|l| l.name == "foo").unwrap();
assert_eq!(foo.effect, "+20"); let bar = labels.iter().find(|l| l.name == "bar").unwrap();
assert_eq!(bar.effect, "+10"); }
#[test]
fn test_merge_labels_case_insensitive() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: Some(vec![LabelEffect {
name: "Urgent".to_string(),
effect: "+10".to_string(),
}]),
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: Some(vec![LabelEffect {
name: "urgent".to_string(),
effect: "+20".to_string(),
}]),
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let labels = result.labels.unwrap();
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].name, "urgent"); assert_eq!(labels[0].effect, "+20"); }
#[test]
fn test_merge_labels_no_global() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: None,
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: Some(vec![LabelEffect {
name: "foo".to_string(),
effect: "+5".to_string(),
}]),
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let labels = result.labels.unwrap();
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].name, "foo");
}
#[test]
fn test_merge_labels_no_query() {
let global = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: Some(vec![LabelEffect {
name: "bar".to_string(),
effect: "+10".to_string(),
}]),
previously_reviewed: None,
draft: None,
};
let query = ScoringConfig {
base_score: None,
age: None,
approvals: None,
size: None,
labels: None,
previously_reviewed: None,
draft: None,
};
let result = merge_scoring_configs(&global, Some(&query));
let labels = result.labels.unwrap();
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].name, "bar");
}
}