use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::error::Result;
const RANKINGS_DIR: &str = "rankings";
pub fn sanitize_user(user: &str) -> String {
let name_part = if let Some(idx) = user.find('<') {
&user[..idx]
} else {
user
};
let slug: String = name_part
.trim()
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect();
let trimmed = slug.trim_matches('-');
if trimmed.is_empty() {
"anonymous".to_string()
} else {
trimmed.to_string()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserOrdering {
pub order: Vec<String>,
#[serde(default)]
pub votes: HashMap<String, i32>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct AggregatedRank {
pub position: usize,
pub score: f64,
pub voter_count: usize,
}
pub fn save_user_ordering(
base: &Path,
milestone_id: &str,
user: &str,
ordering: &UserOrdering,
) -> Result<()> {
let dir = base.join(RANKINGS_DIR).join(milestone_id);
fs::create_dir_all(&dir)?;
let slug = sanitize_user(user);
let file_path = dir.join(format!("{slug}.json"));
let json = serde_json::to_string_pretty(ordering)?;
fs::write(file_path, json)?;
Ok(())
}
pub fn load_user_ordering(
base: &Path,
milestone_id: &str,
user: &str,
) -> Result<Option<UserOrdering>> {
let slug = sanitize_user(user);
let file_path = base
.join(RANKINGS_DIR)
.join(milestone_id)
.join(format!("{slug}.json"));
if !file_path.exists() {
return Ok(None);
}
let contents = fs::read_to_string(file_path)?;
let ordering: UserOrdering = serde_json::from_str(&contents)?;
Ok(Some(ordering))
}
pub fn load_all_orderings(
base: &Path,
milestone_id: &str,
) -> Result<HashMap<String, UserOrdering>> {
let dir = base.join(RANKINGS_DIR).join(milestone_id);
if !dir.exists() {
return Ok(HashMap::new());
}
let mut result = HashMap::new();
for entry in fs::read_dir(&dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let user_slug = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let contents = fs::read_to_string(&path)?;
let ordering: UserOrdering = serde_json::from_str(&contents)?;
result.insert(user_slug, ordering);
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::collections::HashMap;
use tempfile::TempDir;
#[test]
fn test_user_ordering_roundtrip() {
let mut votes = HashMap::new();
votes.insert("problem-1".to_string(), 3i32);
votes.insert("problem-2".to_string(), -1i32);
let ordering = UserOrdering {
order: vec!["problem-1".to_string(), "problem-2".to_string()],
votes,
updated_at: Utc::now(),
};
let json = serde_json::to_string(&ordering).unwrap();
let deserialized: UserOrdering = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.order, ordering.order);
assert_eq!(deserialized.votes.len(), 2);
assert_eq!(deserialized.votes["problem-1"], 3);
assert_eq!(deserialized.votes["problem-2"], -1);
}
#[test]
fn test_user_ordering_empty_votes_default() {
let json = r#"{
"order": ["p1", "p2"],
"updated_at": "2026-03-22T00:00:00Z"
}"#;
let ordering: UserOrdering = serde_json::from_str(json).unwrap();
assert_eq!(ordering.order, vec!["p1", "p2"]);
assert!(ordering.votes.is_empty());
}
#[test]
fn test_save_and_load_ordering() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
let mut votes = HashMap::new();
votes.insert("prob-a".to_string(), 2);
votes.insert("prob-b".to_string(), 5);
let ordering = UserOrdering {
order: vec![
"prob-a".to_string(),
"prob-b".to_string(),
"prob-c".to_string(),
],
votes,
updated_at: Utc::now(),
};
save_user_ordering(
base,
"milestone-1",
"Alice Smith <alice@test.com>",
&ordering,
)
.unwrap();
let loaded = load_user_ordering(base, "milestone-1", "Alice Smith <alice@test.com>")
.unwrap()
.expect("ordering should exist");
assert_eq!(loaded.order, ordering.order);
assert_eq!(loaded.votes.len(), 2);
assert_eq!(loaded.votes["prob-a"], 2);
assert_eq!(loaded.votes["prob-b"], 5);
}
#[test]
fn test_load_nonexistent_ordering_returns_none() {
let tmp = TempDir::new().unwrap();
let result = load_user_ordering(tmp.path(), "no-such-milestone", "nobody").unwrap();
assert!(result.is_none());
}
#[test]
fn test_load_all_orderings() {
let tmp = TempDir::new().unwrap();
let base = tmp.path();
let milestone = "m-all";
let ordering_alice = UserOrdering {
order: vec!["p1".to_string(), "p2".to_string()],
votes: HashMap::new(),
updated_at: Utc::now(),
};
let mut bob_votes = HashMap::new();
bob_votes.insert("p2".to_string(), 3);
let ordering_bob = UserOrdering {
order: vec!["p2".to_string(), "p1".to_string()],
votes: bob_votes,
updated_at: Utc::now(),
};
save_user_ordering(base, milestone, "alice", &ordering_alice).unwrap();
save_user_ordering(base, milestone, "bob", &ordering_bob).unwrap();
let dir = base.join("rankings").join(milestone);
fs::write(dir.join("old-glicko.jsonl"), "should be ignored\n").unwrap();
let all = load_all_orderings(base, milestone).unwrap();
assert_eq!(all.len(), 2);
assert!(all.contains_key("alice"));
assert!(all.contains_key("bob"));
assert_eq!(all["alice"].order, vec!["p1", "p2"]);
assert_eq!(all["bob"].order, vec!["p2", "p1"]);
assert_eq!(all["bob"].votes["p2"], 3);
}
fn simulate_assign(order: &mut Vec<&str>, current_pos: usize, target_pos: usize) {
let id = order.remove(current_pos);
let adjusted = if current_pos < target_pos {
target_pos - 1
} else {
target_pos
};
order.insert(adjusted, id);
}
#[test]
fn test_assign_to_top_from_middle() {
let mut order: Vec<&str> = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i"];
simulate_assign(&mut order, 4, 0);
assert_eq!(order, vec!["e", "a", "b", "c", "d", "f", "g", "h", "i"]);
}
#[test]
fn test_assign_to_top_from_bottom() {
let mut order: Vec<&str> = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i"];
simulate_assign(&mut order, 7, 0);
assert_eq!(order, vec!["h", "a", "b", "c", "d", "e", "f", "g", "i"]);
}
#[test]
fn test_assign_to_bottom_from_top() {
let mut order: Vec<&str> = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i"];
simulate_assign(&mut order, 1, 8);
assert_eq!(order, vec!["a", "c", "d", "e", "f", "g", "h", "b", "i"]);
}
#[test]
fn test_assign_to_bottom_from_middle() {
let mut order: Vec<&str> = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i"];
simulate_assign(&mut order, 4, 8);
assert_eq!(order, vec!["a", "b", "c", "d", "f", "g", "h", "e", "i"]);
}
#[test]
fn test_assign_with_drill_offset() {
let mut order: Vec<&str> = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i"];
simulate_assign(&mut order, 5, 3);
assert_eq!(order, vec!["a", "b", "c", "f", "d", "e", "g", "h", "i"]);
}
#[test]
fn test_assign_to_bottom_with_drill_offset() {
let mut order: Vec<&str> = vec!["a", "b", "c", "d", "e", "f", "g", "h", "i"];
simulate_assign(&mut order, 3, 5);
assert_eq!(order, vec!["a", "b", "c", "e", "d", "f", "g", "h", "i"]);
}
#[test]
fn test_multiple_assigns_stack_at_top() {
let mut order: Vec<&str> = vec!["a", "b", "c", "d", "e", "f"];
simulate_assign(&mut order, 3, 0);
assert_eq!(order, vec!["d", "a", "b", "c", "e", "f"]);
simulate_assign(&mut order, 5, 0);
assert_eq!(order, vec!["f", "d", "a", "b", "c", "e"]);
}
#[test]
fn test_multiple_assigns_stack_at_bottom() {
let mut order: Vec<&str> = vec!["a", "b", "c", "d", "e", "f"];
simulate_assign(&mut order, 1, 5);
assert_eq!(order, vec!["a", "c", "d", "e", "b", "f"]);
simulate_assign(&mut order, 1, 5);
assert_eq!(order, vec!["a", "d", "e", "b", "c", "f"]);
}
fn reorder_by_votes(ord: &mut UserOrdering) {
let votes = &ord.votes;
let mut positive: Vec<String> = Vec::new();
let mut neutral: Vec<String> = Vec::new();
let mut negative: Vec<String> = Vec::new();
for id in &ord.order {
match votes.get(id).copied().unwrap_or(0) {
v if v > 0 => positive.push(id.clone()),
v if v < 0 => negative.push(id.clone()),
_ => neutral.push(id.clone()),
}
}
positive.sort_by(|a, b| {
let va = votes.get(a).copied().unwrap_or(0);
let vb = votes.get(b).copied().unwrap_or(0);
vb.cmp(&va)
});
negative.sort_by(|a, b| {
let va = votes.get(a).copied().unwrap_or(0);
let vb = votes.get(b).copied().unwrap_or(0);
va.cmp(&vb)
});
ord.order.clear();
ord.order.extend(positive);
ord.order.extend(neutral);
ord.order.extend(negative);
}
#[test]
fn test_three_zone_positive_votes_at_top() {
let mut ord = UserOrdering {
order: vec!["a".into(), "b".into(), "c".into(), "d".into()],
votes: HashMap::from([("c".into(), 2)]),
updated_at: Utc::now(),
};
reorder_by_votes(&mut ord);
assert_eq!(ord.order, vec!["c", "a", "b", "d"]);
}
#[test]
fn test_three_zone_negative_votes_at_bottom() {
let mut ord = UserOrdering {
order: vec!["a".into(), "b".into(), "c".into(), "d".into()],
votes: HashMap::from([("a".into(), -1)]),
updated_at: Utc::now(),
};
reorder_by_votes(&mut ord);
assert_eq!(ord.order, vec!["b", "c", "d", "a"]);
}
#[test]
fn test_three_zone_mixed() {
let mut ord = UserOrdering {
order: vec!["a".into(), "b".into(), "c".into(), "d".into(), "e".into()],
votes: HashMap::from([("d".into(), 3), ("b".into(), 1), ("a".into(), -2)]),
updated_at: Utc::now(),
};
reorder_by_votes(&mut ord);
assert_eq!(ord.order, vec!["d", "b", "c", "e", "a"]);
}
#[test]
fn test_three_zone_equal_positive_votes_keep_tier_order() {
let mut ord = UserOrdering {
order: vec!["a".into(), "b".into(), "c".into(), "d".into()],
votes: HashMap::from([("c".into(), 2), ("a".into(), 2)]),
updated_at: Utc::now(),
};
reorder_by_votes(&mut ord);
assert_eq!(ord.order, vec!["a", "c", "b", "d"]);
}
#[test]
fn test_three_zone_no_votes_preserves_order() {
let mut ord = UserOrdering {
order: vec!["a".into(), "b".into(), "c".into()],
votes: HashMap::new(),
updated_at: Utc::now(),
};
reorder_by_votes(&mut ord);
assert_eq!(ord.order, vec!["a", "b", "c"]);
}
#[test]
fn test_three_zone_idempotent() {
let mut ord = UserOrdering {
order: vec!["a".into(), "b".into(), "c".into()],
votes: HashMap::from([("c".into(), 1), ("a".into(), -1)]),
updated_at: Utc::now(),
};
reorder_by_votes(&mut ord);
assert_eq!(ord.order, vec!["c", "b", "a"]);
reorder_by_votes(&mut ord);
assert_eq!(ord.order, vec!["c", "b", "a"]); }
#[test]
fn test_three_zone_magnitude_ordering() {
let mut ord = UserOrdering {
order: vec!["a".into(), "b".into(), "c".into(), "d".into()],
votes: HashMap::from([("c".into(), 10), ("a".into(), 4)]),
updated_at: Utc::now(),
};
reorder_by_votes(&mut ord);
assert_eq!(ord.order[0], "c"); assert_eq!(ord.order[1], "a"); }
}