From 9aee850d999f7e829891387083e7fb6e2d64e486 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Tue, 21 Jul 2026 19:48:11 +0100 Subject: [PATCH] feat(leaderboard): implement global leaderboard with local file caching and CLI support --- Cargo.toml | 1 + crates/leaderboard/Cargo.toml | 14 +++ crates/leaderboard/src/cli.rs | 76 +++++++++++++++ crates/leaderboard/src/lib.rs | 13 +++ crates/leaderboard/src/local_file.rs | 102 ++++++++++++++++++++ crates/leaderboard/src/main.rs | 16 +++ crates/leaderboard/src/models.rs | 72 ++++++++++++++ crates/leaderboard/src/remote.rs | 50 ++++++++++ crates/leaderboard/src/tests.rs | 139 +++++++++++++++++++++++++++ crates/leaderboard/src/trait_def.rs | 14 +++ 10 files changed, 497 insertions(+) create mode 100644 crates/leaderboard/Cargo.toml create mode 100644 crates/leaderboard/src/cli.rs create mode 100644 crates/leaderboard/src/lib.rs create mode 100644 crates/leaderboard/src/local_file.rs create mode 100644 crates/leaderboard/src/main.rs create mode 100644 crates/leaderboard/src/models.rs create mode 100644 crates/leaderboard/src/remote.rs create mode 100644 crates/leaderboard/src/tests.rs create mode 100644 crates/leaderboard/src/trait_def.rs diff --git a/Cargo.toml b/Cargo.toml index 2c705a1..7ecc9ef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "contracts/registry", "contracts/escrow", "contracts/settlement", + "crates/leaderboard", ] [workspace.dependencies] diff --git a/crates/leaderboard/Cargo.toml b/crates/leaderboard/Cargo.toml new file mode 100644 index 0000000..ebdfd6b --- /dev/null +++ b/crates/leaderboard/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "leaderboard" +version = "0.1.0" +edition = "2021" +description = "Global leaderboard with local file caching and CLI support" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +clap = { version = "4.4", features = ["derive"] } +thiserror = "1.0" + +[dev-dependencies] +tempfile = "3.8" diff --git a/crates/leaderboard/src/cli.rs b/crates/leaderboard/src/cli.rs new file mode 100644 index 0000000..948d97e --- /dev/null +++ b/crates/leaderboard/src/cli.rs @@ -0,0 +1,76 @@ +use crate::local_file::LocalFileLeaderboard; +use crate::models::{LeaderboardEntry, SortBy}; +use crate::trait_def::Leaderboard; +use clap::Parser; +use std::path::PathBuf; + +/// Command line interface options for the Leaderboard application. +#[derive(Parser, Debug)] +#[command(name = "leaderboard")] +#[command(about = "Display and manage the global puzzle leaderboard")] +pub struct CliArgs { + /// Display the leaderboard + #[arg(long)] + pub leaderboard: bool, + + /// Sorting criteria (score or recency) + #[arg(long, default_value = "score")] + pub sort: String, + + /// Number of top entries to display + #[arg(long, default_value_t = 10)] + pub limit: usize, + + /// Custom file path for the local leaderboard database + #[arg(long)] + pub file: Option, +} + +/// Formats and displays leaderboard entries as a CLI table. +pub fn format_leaderboard_table(entries: &[LeaderboardEntry]) -> String { + if entries.is_empty() { + return "No leaderboard entries found.".to_string(); + } + + let mut out = String::new(); + out.push_str(&format!( + "{:<6} {:<20} {:<20} {:<10} {:<20}\n", + "Rank", "Player Name", "Puzzle Type", "Score", "Timestamp" + )); + out.push_str(&format!("{}\n", "-".repeat(78))); + + for (idx, entry) in entries.iter().enumerate() { + out.push_str(&format!( + "{:<6} {:<20} {:<20} {:<10} {:<20}\n", + idx + 1, + entry.player_name, + entry.puzzle_type, + entry.score, + entry.timestamp + )); + } + + out +} + +/// Executes the CLI command with the given arguments and leaderboard implementation. +pub fn run_cli_with_args(args: &CliArgs, leaderboard: &L) -> Result, String> { + if args.leaderboard { + let sort_by: SortBy = args.sort.parse()?; + let top_entries = leaderboard + .get_top_entries(args.limit, sort_by) + .map_err(|e| e.to_string())?; + + let output = format_leaderboard_table(&top_entries); + Ok(Some(output)) + } else { + Ok(None) + } +} + +/// Runs the default CLI workflow loading entries from `LocalFileLeaderboard`. +pub fn run_cli(args: &CliArgs) -> Result, String> { + let path = args.file.clone().unwrap_or_else(LocalFileLeaderboard::default_path); + let leaderboard = LocalFileLeaderboard::new(path); + run_cli_with_args(args, &leaderboard) +} diff --git a/crates/leaderboard/src/lib.rs b/crates/leaderboard/src/lib.rs new file mode 100644 index 0000000..2635587 --- /dev/null +++ b/crates/leaderboard/src/lib.rs @@ -0,0 +1,13 @@ +pub mod cli; +pub mod local_file; +pub mod models; +pub mod remote; +pub mod trait_def; + +#[cfg(test)] +mod tests; + +pub use local_file::LocalFileLeaderboard; +pub use models::{LeaderboardEntry, LeaderboardError, SortBy}; +pub use remote::RemoteLeaderboard; +pub use trait_def::Leaderboard; diff --git a/crates/leaderboard/src/local_file.rs b/crates/leaderboard/src/local_file.rs new file mode 100644 index 0000000..3791703 --- /dev/null +++ b/crates/leaderboard/src/local_file.rs @@ -0,0 +1,102 @@ +use crate::models::{LeaderboardEntry, LeaderboardError, SortBy}; +use crate::trait_def::Leaderboard; +use std::fs::{self, File}; +use std::io::{BufReader, BufWriter}; +use std::path::{Path, PathBuf}; + +/// A local file-backed leaderboard implementation that persists score entries to disk. +pub struct LocalFileLeaderboard { + file_path: PathBuf, +} + +impl LocalFileLeaderboard { + /// Creates a new `LocalFileLeaderboard` using the specified file path. + pub fn new(path: impl AsRef) -> Self { + Self { + file_path: path.as_ref().to_path_buf(), + } + } + + /// Returns the default system storage path (`~/.mesh/leaderboard.json` or fallback `./leaderboard.json`). + pub fn default_path() -> PathBuf { + if let Some(mut dir) = dirs_next() { + dir.push(".mesh"); + dir.push("leaderboard.json"); + dir + } else { + PathBuf::from("./leaderboard.json") + } + } + + /// Creates a `LocalFileLeaderboard` instance with the default file path. + pub fn with_default_path() -> Self { + Self::new(Self::default_path()) + } + + /// Returns the file path of this leaderboard instance. + pub fn path(&self) -> &Path { + &self.file_path + } + + fn read_entries(&self) -> Result, LeaderboardError> { + if !self.file_path.exists() { + return Ok(Vec::new()); + } + + let file = File::open(&self.file_path)?; + let reader = BufReader::new(file); + let entries: Vec = serde_json::from_reader(reader).unwrap_or_default(); + Ok(entries) + } + + fn write_entries(&self, entries: &[LeaderboardEntry]) -> Result<(), LeaderboardError> { + if let Some(parent) = self.file_path.parent() { + if !parent.exists() { + fs::create_dir_all(parent)?; + } + } + + let file = File::create(&self.file_path)?; + let writer = BufWriter::new(file); + serde_json::to_writer_pretty(writer, entries)?; + Ok(()) + } + + /// Sorts entries according to `SortBy`. + pub fn sort_entries(entries: &mut [LeaderboardEntry], sort_by: SortBy) { + match sort_by { + SortBy::Score => { + entries.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| b.timestamp.cmp(&a.timestamp))); + } + SortBy::Recency => { + entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then_with(|| b.score.cmp(&a.score))); + } + } + } +} + +fn dirs_next() -> Option { + #[allow(deprecated)] + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} + +impl Leaderboard for LocalFileLeaderboard { + fn add_entry(&mut self, entry: LeaderboardEntry) -> Result<(), LeaderboardError> { + let mut entries = self.read_entries()?; + entries.push(entry); + self.write_entries(&entries) + } + + fn get_top_entries(&self, limit: usize, sort_by: SortBy) -> Result, LeaderboardError> { + let mut entries = self.read_entries()?; + Self::sort_entries(&mut entries, sort_by); + entries.truncate(limit); + Ok(entries) + } + + fn get_all_entries(&self) -> Result, LeaderboardError> { + self.read_entries() + } +} diff --git a/crates/leaderboard/src/main.rs b/crates/leaderboard/src/main.rs new file mode 100644 index 0000000..abbaf29 --- /dev/null +++ b/crates/leaderboard/src/main.rs @@ -0,0 +1,16 @@ +use clap::Parser; +use leaderboard::cli::{run_cli, CliArgs}; + +fn main() { + let args = CliArgs::parse(); + match run_cli(&args) { + Ok(Some(output)) => println!("{}", output), + Ok(None) => { + println!("Use --leaderboard to display the top scores."); + } + Err(err) => { + eprintln!("Error: {}", err); + std::process::exit(1); + } + } +} diff --git a/crates/leaderboard/src/models.rs b/crates/leaderboard/src/models.rs new file mode 100644 index 0000000..15f8554 --- /dev/null +++ b/crates/leaderboard/src/models.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use thiserror::Error; + +/// An individual entry recorded on the leaderboard. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LeaderboardEntry { + /// Name of the player + pub player_name: String, + /// Type or identifier of the puzzle completed + pub puzzle_type: String, + /// Score achieved by the player + pub score: u64, + /// Unix timestamp (in seconds) when score was recorded + pub timestamp: u64, +} + +impl LeaderboardEntry { + /// Creates a new leaderboard entry with the specified parameters. + pub fn new(player_name: impl Into, puzzle_type: impl Into, score: u64, timestamp: u64) -> Self { + Self { + player_name: player_name.into(), + puzzle_type: puzzle_type.into(), + score, + timestamp, + } + } +} + +/// Criteria used to sort leaderboard entries. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum SortBy { + /// Sort by highest score first + #[default] + Score, + /// Sort by most recent timestamp first + Recency, +} + +impl std::str::FromStr for SortBy { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "score" => Ok(SortBy::Score), + "recency" => Ok(SortBy::Recency), + _ => Err(format!("Unknown sort order '{}'. Options: score, recency", s)), + } + } +} + +impl fmt::Display for SortBy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SortBy::Score => write!(f, "score"), + SortBy::Recency => write!(f, "recency"), + } + } +} + +/// Errors returned during leaderboard operations. +#[derive(Debug, Error)] +pub enum LeaderboardError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("Invalid argument: {0}")] + InvalidInput(String), +} diff --git a/crates/leaderboard/src/remote.rs b/crates/leaderboard/src/remote.rs new file mode 100644 index 0000000..2ef7592 --- /dev/null +++ b/crates/leaderboard/src/remote.rs @@ -0,0 +1,50 @@ +use crate::models::{LeaderboardEntry, LeaderboardError, SortBy}; +use crate::trait_def::Leaderboard; + +/// A stub implementation of a remote, HTTP-backed leaderboard. +/// Demonstrates that callers using the `Leaderboard` trait can seamlessly switch backends. +pub struct RemoteLeaderboard { + endpoint_url: String, + in_memory_cache: Vec, +} + +impl RemoteLeaderboard { + /// Creates a new `RemoteLeaderboard` instance configured with an endpoint URL. + pub fn new(endpoint_url: impl Into) -> Self { + Self { + endpoint_url: endpoint_url.into(), + in_memory_cache: Vec::new(), + } + } + + /// Returns the endpoint URL. + pub fn endpoint_url(&self) -> &str { + &self.endpoint_url + } +} + +impl Leaderboard for RemoteLeaderboard { + fn add_entry(&mut self, entry: LeaderboardEntry) -> Result<(), LeaderboardError> { + // In a real implementation, this would send an HTTP POST request to endpoint_url. + self.in_memory_cache.push(entry); + Ok(()) + } + + fn get_top_entries(&self, limit: usize, sort_by: SortBy) -> Result, LeaderboardError> { + let mut entries = self.in_memory_cache.clone(); + match sort_by { + SortBy::Score => { + entries.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| b.timestamp.cmp(&a.timestamp))); + } + SortBy::Recency => { + entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp).then_with(|| b.score.cmp(&a.score))); + } + } + entries.truncate(limit); + Ok(entries) + } + + fn get_all_entries(&self) -> Result, LeaderboardError> { + Ok(self.in_memory_cache.clone()) + } +} diff --git a/crates/leaderboard/src/tests.rs b/crates/leaderboard/src/tests.rs new file mode 100644 index 0000000..9fd8125 --- /dev/null +++ b/crates/leaderboard/src/tests.rs @@ -0,0 +1,139 @@ +use super::*; +use crate::cli::{format_leaderboard_table, run_cli_with_args, CliArgs}; +use tempfile::NamedTempFile; + +#[test] +fn test_entry_creation() { + let entry = LeaderboardEntry::new("Alice", "Sudoku", 950, 1600000000); + assert_eq!(entry.player_name, "Alice"); + assert_eq!(entry.puzzle_type, "Sudoku"); + assert_eq!(entry.score, 950); + assert_eq!(entry.timestamp, 1600000000); +} + +#[test] +fn test_insertion_and_persistence() { + let temp_file = NamedTempFile::new().unwrap(); + let file_path = temp_file.path().to_path_buf(); + + { + let mut leaderboard = LocalFileLeaderboard::new(&file_path); + let entry1 = LeaderboardEntry::new("Alice", "Crossword", 500, 100); + let entry2 = LeaderboardEntry::new("Bob", "Crossword", 800, 200); + + leaderboard.add_entry(entry1).unwrap(); + leaderboard.add_entry(entry2).unwrap(); + } + + // Reload from file in a new instance to verify persistence across sessions + let leaderboard = LocalFileLeaderboard::new(&file_path); + let entries = leaderboard.get_all_entries().unwrap(); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].player_name, "Alice"); + assert_eq!(entries[1].player_name, "Bob"); +} + +#[test] +fn test_sorting_by_score() { + let temp_file = NamedTempFile::new().unwrap(); + let mut leaderboard = LocalFileLeaderboard::new(temp_file.path()); + + leaderboard.add_entry(LeaderboardEntry::new("Player1", "Math", 100, 1000)).unwrap(); + leaderboard.add_entry(LeaderboardEntry::new("Player2", "Math", 300, 1000)).unwrap(); + leaderboard.add_entry(LeaderboardEntry::new("Player3", "Math", 200, 1000)).unwrap(); + + let top = leaderboard.get_top_entries(10, SortBy::Score).unwrap(); + + assert_eq!(top.len(), 3); + assert_eq!(top[0].player_name, "Player2"); // Score 300 + assert_eq!(top[1].player_name, "Player3"); // Score 200 + assert_eq!(top[2].player_name, "Player1"); // Score 100 +} + +#[test] +fn test_sorting_by_recency() { + let temp_file = NamedTempFile::new().unwrap(); + let mut leaderboard = LocalFileLeaderboard::new(temp_file.path()); + + leaderboard.add_entry(LeaderboardEntry::new("Old", "Logic", 500, 1000)).unwrap(); + leaderboard.add_entry(LeaderboardEntry::new("Newest", "Logic", 100, 3000)).unwrap(); + leaderboard.add_entry(LeaderboardEntry::new("Medium", "Logic", 900, 2000)).unwrap(); + + let top = leaderboard.get_top_entries(10, SortBy::Recency).unwrap(); + + assert_eq!(top.len(), 3); + assert_eq!(top[0].player_name, "Newest"); // Timestamp 3000 + assert_eq!(top[1].player_name, "Medium"); // Timestamp 2000 + assert_eq!(top[2].player_name, "Old"); // Timestamp 1000 +} + +#[test] +fn test_top_n_retrieval() { + let temp_file = NamedTempFile::new().unwrap(); + let mut leaderboard = LocalFileLeaderboard::new(temp_file.path()); + + for i in 1..=15 { + leaderboard + .add_entry(LeaderboardEntry::new( + format!("Player{}", i), + "Puzzle", + i * 10, + 1000 + i, + )) + .unwrap(); + } + + // Default --leaderboard displays top 10 entries + let top10 = leaderboard.get_top_entries(10, SortBy::Score).unwrap(); + assert_eq!(top10.len(), 10); + assert_eq!(top10[0].player_name, "Player15"); + assert_eq!(top10[0].score, 150); + assert_eq!(top10[9].player_name, "Player6"); + assert_eq!(top10[9].score, 60); +} + +#[test] +fn test_remote_leaderboard_trait_compatibility() { + fn add_score(leaderboard: &mut L) { + leaderboard + .add_entry(LeaderboardEntry::new("RemotePlayer", "Network", 999, 5000)) + .unwrap(); + } + + let mut remote = RemoteLeaderboard::new("https://api.leaderboard.example.com"); + add_score(&mut remote); + + let top = remote.get_top_entries(10, SortBy::Score).unwrap(); + assert_eq!(top.len(), 1); + assert_eq!(top[0].player_name, "RemotePlayer"); + assert_eq!(remote.endpoint_url(), "https://api.leaderboard.example.com"); +} + +#[test] +fn test_cli_formatting_and_execution() { + let temp_file = NamedTempFile::new().unwrap(); + let mut leaderboard = LocalFileLeaderboard::new(temp_file.path()); + + leaderboard + .add_entry(LeaderboardEntry::new("Champ", "Wordle", 1000, 1700000000)) + .unwrap(); + + let args = CliArgs { + leaderboard: true, + sort: "score".to_string(), + limit: 10, + file: Some(temp_file.path().to_path_buf()), + }; + + let result = run_cli_with_args(&args, &leaderboard).unwrap(); + assert!(result.is_some()); + let output = result.unwrap(); + + assert!(output.contains("Champ")); + assert!(output.contains("Wordle")); + assert!(output.contains("1000")); + + let table_empty = format_leaderboard_table(&[]); + assert_eq!(table_empty, "No leaderboard entries found."); +} diff --git a/crates/leaderboard/src/trait_def.rs b/crates/leaderboard/src/trait_def.rs new file mode 100644 index 0000000..470c7c3 --- /dev/null +++ b/crates/leaderboard/src/trait_def.rs @@ -0,0 +1,14 @@ +use crate::models::{LeaderboardEntry, LeaderboardError, SortBy}; + +/// Core trait defining operations for leaderboard storage and retrieval. +/// Designed to support both local caching (`LocalFileLeaderboard`) and future network backends (`RemoteLeaderboard`). +pub trait Leaderboard { + /// Inserts a new score entry into the leaderboard. + fn add_entry(&mut self, entry: LeaderboardEntry) -> Result<(), LeaderboardError>; + + /// Retrieves top N entries sorted by the specified criteria (`SortBy::Score` or `SortBy::Recency`). + fn get_top_entries(&self, limit: usize, sort_by: SortBy) -> Result, LeaderboardError>; + + /// Retrieves all recorded entries. + fn get_all_entries(&self) -> Result, LeaderboardError>; +}