Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = [
"contracts/registry",
"contracts/escrow",
"contracts/settlement",
"crates/leaderboard",
"contracts/puzzle",
"contracts/scoring",
]
Expand Down
14 changes: 14 additions & 0 deletions crates/leaderboard/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
76 changes: 76 additions & 0 deletions crates/leaderboard/src/cli.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf>,
}

/// 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<L: Leaderboard>(args: &CliArgs, leaderboard: &L) -> Result<Option<String>, 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<Option<String>, String> {
let path = args.file.clone().unwrap_or_else(LocalFileLeaderboard::default_path);
let leaderboard = LocalFileLeaderboard::new(path);
run_cli_with_args(args, &leaderboard)
}
13 changes: 13 additions & 0 deletions crates/leaderboard/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
102 changes: 102 additions & 0 deletions crates/leaderboard/src/local_file.rs
Original file line number Diff line number Diff line change
@@ -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<Path>) -> 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<Vec<LeaderboardEntry>, 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<LeaderboardEntry> = 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<PathBuf> {
#[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<Vec<LeaderboardEntry>, 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<Vec<LeaderboardEntry>, LeaderboardError> {
self.read_entries()
}
}
16 changes: 16 additions & 0 deletions crates/leaderboard/src/main.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
72 changes: 72 additions & 0 deletions crates/leaderboard/src/models.rs
Original file line number Diff line number Diff line change
@@ -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<String>, puzzle_type: impl Into<String>, 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<Self, Self::Err> {
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),
}
50 changes: 50 additions & 0 deletions crates/leaderboard/src/remote.rs
Original file line number Diff line number Diff line change
@@ -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<LeaderboardEntry>,
}

impl RemoteLeaderboard {
/// Creates a new `RemoteLeaderboard` instance configured with an endpoint URL.
pub fn new(endpoint_url: impl Into<String>) -> 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<Vec<LeaderboardEntry>, 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<Vec<LeaderboardEntry>, LeaderboardError> {
Ok(self.in_memory_cache.clone())
}
}
Loading