diff --git a/src/cli.rs b/src/cli.rs index f9a383b..1a1f9da 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,5 @@ use boha::{ - arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision, zden, Author, Chain, - PubkeyFormat, Puzzle, Stats, Status, TransactionType, + b1000, Author, Chain, Collection, PubkeyFormat, Puzzle, Stats, Status, TransactionType, }; use chrono::Utc; use clap::{Parser, Subcommand, ValueEnum}; @@ -1148,23 +1147,8 @@ fn cmd_search( } let puzzles: Vec<&'static Puzzle> = match collection { - Some("arweave") => arweave::all().collect(), - Some("b1000") => b1000::all().collect(), - Some("ballet") => ballet::all().collect(), - Some("bitaps") => bitaps::all().collect(), - Some("bitimage") => bitimage::all().collect(), - Some("gsmg") => gsmg::all().collect(), - Some("hash_collision" | "peter_todd") => hash_collision::all().collect(), - Some("zden") => zden::all().collect(), Some("all") | None => boha::all().collect(), - Some(collection) => { - eprintln!( - "{} Unknown collection: {}. Use: arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), zden, all", - "Error:".red().bold(), - collection - ); - std::process::exit(1); - } + Some(collection) => collection_or_exit(collection, true).all().collect(), }; let mut results: Vec = puzzles @@ -1202,24 +1186,10 @@ fn cmd_list( chain_filter: Option, format: OutputFormat, ) { - let puzzles: Vec<&Puzzle> = match collection { - "arweave" => arweave::all().collect(), - "b1000" => b1000::all().collect(), - "ballet" => ballet::all().collect(), - "bitaps" => bitaps::all().collect(), - "bitimage" => bitimage::all().collect(), - "gsmg" => gsmg::all().collect(), - "hash_collision" | "peter_todd" => hash_collision::all().collect(), - "zden" => zden::all().collect(), - "all" => boha::all().collect(), - _ => { - eprintln!( - "{} Unknown collection: {}. Use: arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), zden, all", - "Error:".red().bold(), - collection - ); - std::process::exit(1); - } + let puzzles: Vec<&Puzzle> = if collection == "all" { + boha::all().collect() + } else { + collection_or_exit(collection, true).all().collect() }; let filtered: Vec<_> = puzzles @@ -1284,24 +1254,7 @@ fn cmd_range(puzzle_number: u32, format: OutputFormat) { } fn cmd_author(collection: &str, format: OutputFormat) { - let author = match collection { - "arweave" => arweave::author(), - "b1000" => b1000::author(), - "ballet" => ballet::author(), - "bitaps" => bitaps::author(), - "bitimage" => bitimage::author(), - "gsmg" => gsmg::author(), - "hash_collision" | "peter_todd" => hash_collision::author(), - "zden" => zden::author(), - _ => { - eprintln!( - "{} Unknown collection: {}. Use: arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), zden", - "Error:".red().bold(), - collection - ); - std::process::exit(1); - } - }; + let author = collection_or_exit(collection, false).author(); output_author(author, format); } @@ -1786,46 +1739,22 @@ fn cmd_export( ) { use std::collections::HashSet; - const ALL_COLLECTIONS: &[&str] = &[ - "arweave", - "b1000", - "ballet", - "bitaps", - "bitimage", - "gsmg", - "hash_collision", - "zden", - ]; - let mut seen = HashSet::new(); #[allow(clippy::useless_let_if_seq)] let mut collections_to_export = Vec::new(); if collections.is_empty() { - collections_to_export = ALL_COLLECTIONS.iter().map(ToString::to_string).collect(); + collections_to_export = Collection::ALL.to_vec(); } else { for collection in collections { if collection == "all" { - collections_to_export = ALL_COLLECTIONS.iter().map(ToString::to_string).collect(); + collections_to_export = Collection::ALL.to_vec(); break; } - let canonical = if collection == "peter_todd" { - "hash_collision".to_string() - } else { - collection - }; - - if !ALL_COLLECTIONS.contains(&canonical.as_str()) { - eprintln!( - "{} Unknown collection: {}. Use: arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), zden, all", - "Error:".red().bold(), - canonical - ); - std::process::exit(1); - } + let canonical = collection_or_exit(&collection, true); - if seen.insert(canonical.clone()) { + if seen.insert(canonical.name()) { collections_to_export.push(canonical); } } @@ -1833,31 +1762,13 @@ fn cmd_export( let mut export_collections = Vec::new(); - for collection_name in collections_to_export { - let (name, author, puzzles): (&str, Option<&Author>, Vec<&Puzzle>) = - match collection_name.as_str() { - "arweave" => ("arweave", Some(arweave::author()), arweave::all().collect()), - "b1000" => ("b1000", Some(b1000::author()), b1000::all().collect()), - "ballet" => ("ballet", Some(ballet::author()), ballet::all().collect()), - "bitaps" => ("bitaps", Some(bitaps::author()), bitaps::all().collect()), - "bitimage" => ( - "bitimage", - Some(bitimage::author()), - bitimage::all().collect(), - ), - "gsmg" => ("gsmg", Some(gsmg::author()), gsmg::all().collect()), - "hash_collision" => ( - "hash_collision", - Some(hash_collision::author()), - hash_collision::all().collect(), - ), - "zden" => ("zden", Some(zden::author()), zden::all().collect()), - _ => unreachable!(), // Already validated above - }; + for collection in collections_to_export { + let name = collection.name(); + let author = Some(collection.author()); // Apply status filtering - let filtered: Vec<_> = puzzles - .into_iter() + let filtered: Vec<_> = collection + .all() .filter(|p| !unsolved || p.status == Status::Unsolved) .filter(|p| !solved || p.status == Status::Solved) .collect(); @@ -1912,6 +1823,36 @@ fn cmd_export( output_export(&export_data, format, compact); } +fn collection_help(include_all: bool) -> String { + let mut names: Vec<_> = Collection::ALL + .iter() + .map(|collection| match collection { + Collection::HashCollision => "hash_collision (peter_todd)", + _ => collection.name(), + }) + .collect(); + + if include_all { + names.push("all"); + } + + names.join(", ") +} + +fn collection_or_exit(name: &str, include_all: bool) -> Collection { + if let Ok(collection) = Collection::parse(name) { + collection + } else { + eprintln!( + "{} Unknown collection: {}. Use: {}", + "Error:".red().bold(), + name, + collection_help(include_all) + ); + std::process::exit(1); + } +} + fn output_export(data: &ExportData, format: OutputFormat, compact: bool) { match format { OutputFormat::Table => { @@ -1944,3 +1885,20 @@ fn output_export(data: &ExportData, format: OutputFormat, compact: bool) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collection_help_lists_registry_names() { + assert_eq!( + collection_help(false), + "arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), zden" + ); + assert_eq!( + collection_help(true), + "arweave, b1000, ballet, bitaps, bitimage, gsmg, hash_collision (peter_todd), zden, all" + ); + } +} diff --git a/src/collections/arweave.rs b/src/collections/arweave.rs index f62b3ce..43e0d87 100644 --- a/src/collections/arweave.rs +++ b/src/collections/arweave.rs @@ -24,8 +24,12 @@ pub fn get(name: &str) -> Result<&'static Puzzle> { .ok_or(Error::NotFound(search_id)) } +pub fn slice() -> &'static [Puzzle] { + PUZZLES +} + pub fn all() -> impl Iterator { - PUZZLES.iter() + slice().iter() } pub fn solved() -> impl Iterator { diff --git a/src/collections/b1000.rs b/src/collections/b1000.rs index 721a9a5..3d61eed 100644 --- a/src/collections/b1000.rs +++ b/src/collections/b1000.rs @@ -26,8 +26,12 @@ pub fn get(key: impl IntoPuzzleNum) -> Result<&'static Puzzle> { .ok_or_else(|| Error::NotFound(format!("b1000/{}", number))) } +pub fn slice() -> &'static [Puzzle] { + PUZZLES +} + pub fn all() -> impl Iterator { - PUZZLES.iter() + slice().iter() } pub fn solved() -> impl Iterator { diff --git a/src/collections/ballet.rs b/src/collections/ballet.rs index 86e6c51..341d835 100644 --- a/src/collections/ballet.rs +++ b/src/collections/ballet.rs @@ -24,8 +24,12 @@ pub fn get(name: &str) -> Result<&'static Puzzle> { .ok_or(Error::NotFound(search_id)) } +pub fn slice() -> &'static [Puzzle] { + PUZZLES +} + pub fn all() -> impl Iterator { - PUZZLES.iter() + slice().iter() } pub fn solved() -> impl Iterator { diff --git a/src/collections/bitaps.rs b/src/collections/bitaps.rs index 754b57b..8de5690 100644 --- a/src/collections/bitaps.rs +++ b/src/collections/bitaps.rs @@ -16,8 +16,12 @@ pub fn get() -> &'static Puzzle { &PUZZLE } +pub fn slice() -> &'static [Puzzle] { + std::slice::from_ref(&PUZZLE) +} + pub fn all() -> impl Iterator { - std::iter::once(&PUZZLE) + slice().iter() } pub const fn count() -> usize { diff --git a/src/collections/bitimage.rs b/src/collections/bitimage.rs index b0c62cd..00ba4ba 100644 --- a/src/collections/bitimage.rs +++ b/src/collections/bitimage.rs @@ -24,8 +24,12 @@ pub fn get(name: &str) -> Result<&'static Puzzle> { .ok_or(Error::NotFound(search_id)) } +pub fn slice() -> &'static [Puzzle] { + PUZZLES +} + pub fn all() -> impl Iterator { - PUZZLES.iter() + slice().iter() } pub fn solved() -> impl Iterator { diff --git a/src/collections/gsmg.rs b/src/collections/gsmg.rs index d4eeb7b..3c8f12a 100644 --- a/src/collections/gsmg.rs +++ b/src/collections/gsmg.rs @@ -16,8 +16,12 @@ pub fn get() -> &'static Puzzle { &PUZZLE } +pub fn slice() -> &'static [Puzzle] { + std::slice::from_ref(&PUZZLE) +} + pub fn all() -> impl Iterator { - std::iter::once(&PUZZLE) + slice().iter() } pub const fn count() -> usize { diff --git a/src/collections/hash_collision.rs b/src/collections/hash_collision.rs index 1e1d974..45f3bbd 100644 --- a/src/collections/hash_collision.rs +++ b/src/collections/hash_collision.rs @@ -25,8 +25,12 @@ pub fn get(name: &str) -> Result<&'static Puzzle> { .ok_or(Error::NotFound(search_id)) } +pub fn slice() -> &'static [Puzzle] { + PUZZLES +} + pub fn all() -> impl Iterator { - PUZZLES.iter() + slice().iter() } pub fn solved() -> impl Iterator { diff --git a/src/collections/zden.rs b/src/collections/zden.rs index b9449e1..800fb24 100644 --- a/src/collections/zden.rs +++ b/src/collections/zden.rs @@ -24,8 +24,12 @@ pub fn get(name: &str) -> Result<&'static Puzzle> { .ok_or(Error::NotFound(search_id)) } +pub fn slice() -> &'static [Puzzle] { + PUZZLES +} + pub fn all() -> impl Iterator { - PUZZLES.iter() + slice().iter() } pub fn solved() -> impl Iterator { diff --git a/src/lib.rs b/src/lib.rs index 18ab43b..11bae2b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,44 +33,142 @@ pub enum Error { pub type Result = std::result::Result; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Collection { + Arweave, + B1000, + Ballet, + Bitaps, + Bitimage, + Gsmg, + HashCollision, + Zden, +} + +impl Collection { + pub const ALL: [Self; 8] = [ + Self::Arweave, + Self::B1000, + Self::Ballet, + Self::Bitaps, + Self::Bitimage, + Self::Gsmg, + Self::HashCollision, + Self::Zden, + ]; + + pub const fn name(self) -> &'static str { + match self { + Self::Arweave => "arweave", + Self::B1000 => "b1000", + Self::Ballet => "ballet", + Self::Bitaps => "bitaps", + Self::Bitimage => "bitimage", + Self::Gsmg => "gsmg", + Self::HashCollision => "hash_collision", + Self::Zden => "zden", + } + } + + pub fn parse(name: &str) -> Result { + match name { + "arweave" => Ok(Self::Arweave), + "b1000" => Ok(Self::B1000), + "ballet" => Ok(Self::Ballet), + "bitaps" => Ok(Self::Bitaps), + "bitimage" => Ok(Self::Bitimage), + "gsmg" => Ok(Self::Gsmg), + "hash_collision" | "peter_todd" => Ok(Self::HashCollision), + "zden" => Ok(Self::Zden), + _ => Err(Error::InvalidCollection(name.to_string())), + } + } + + pub fn slice(self) -> &'static [Puzzle] { + match self { + Self::Arweave => arweave::slice(), + Self::B1000 => b1000::slice(), + Self::Ballet => ballet::slice(), + Self::Bitaps => bitaps::slice(), + Self::Bitimage => bitimage::slice(), + Self::Gsmg => gsmg::slice(), + Self::HashCollision => hash_collision::slice(), + Self::Zden => zden::slice(), + } + } + + pub fn all(self) -> std::slice::Iter<'static, Puzzle> { + self.slice().iter() + } + + pub fn author(self) -> &'static Author { + match self { + Self::Arweave => arweave::author(), + Self::B1000 => b1000::author(), + Self::Ballet => ballet::author(), + Self::Bitaps => bitaps::author(), + Self::Bitimage => bitimage::author(), + Self::Gsmg => gsmg::author(), + Self::HashCollision => hash_collision::author(), + Self::Zden => zden::author(), + } + } + + pub fn get(self, name: &str) -> Result<&'static Puzzle> { + match self { + Self::Arweave => arweave::get(name), + Self::B1000 => { + let num = name + .parse::() + .map_err(|_| Error::NotFound(format!("{}/{}", self.name(), name)))?; + b1000::get(num) + } + Self::Ballet => ballet::get(name), + Self::Bitaps => { + if name.is_empty() { + Ok(bitaps::get()) + } else { + Err(Error::NotFound(format!("{}/{}", self.name(), name))) + } + } + Self::Bitimage => bitimage::get(name), + Self::Gsmg => { + if name.is_empty() { + Ok(gsmg::get()) + } else { + Err(Error::NotFound(format!("{}/{}", self.name(), name))) + } + } + Self::HashCollision => hash_collision::get(name), + Self::Zden => zden::get(name), + } + } +} + pub fn get(id: &str) -> Result<&'static Puzzle> { if id == "gsmg" { - return Ok(gsmg::get()); + return Collection::Gsmg.get(""); } if id == "bitaps" { - return Ok(bitaps::get()); + return Collection::Bitaps.get(""); } let parts: Vec<&str> = id.split('/').collect(); - if parts.len() < 2 { + if parts.len() != 2 { return Err(Error::NotFound(id.to_string())); } - match parts[0] { - "arweave" => arweave::get(parts[1]), - "b1000" => { - let num: u32 = parts[1] - .parse() - .map_err(|_| Error::NotFound(id.to_string()))?; - b1000::get(num) - } - "ballet" => ballet::get(parts[1]), - "bitimage" => bitimage::get(parts[1]), - "hash_collision" | "peter_todd" => hash_collision::get(parts[1]), - "zden" => zden::get(parts[1]), - _ => Err(Error::NotFound(id.to_string())), + let collection = Collection::parse(parts[0]).map_err(|_| Error::NotFound(id.to_string()))?; + + if matches!(collection, Collection::Gsmg | Collection::Bitaps) { + return Err(Error::NotFound(id.to_string())); } + + collection.get(parts[1]) } pub fn all() -> impl Iterator { - arweave::all() - .chain(b1000::all()) - .chain(ballet::all()) - .chain(bitaps::all()) - .chain(bitimage::all()) - .chain(gsmg::all()) - .chain(hash_collision::all()) - .chain(zden::all()) + Collection::ALL.into_iter().flat_map(Collection::all) } #[derive(Debug, Default, Clone, serde::Serialize)] @@ -109,3 +207,93 @@ pub fn stats() -> Stats { stats } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collection_parse_supports_aliases() { + assert_eq!(Collection::parse("arweave").unwrap(), Collection::Arweave); + assert_eq!( + Collection::parse("hash_collision").unwrap(), + Collection::HashCollision + ); + assert_eq!( + Collection::parse("peter_todd").unwrap(), + Collection::HashCollision + ); + } + + #[test] + fn collection_all_matches_global_iterator() { + let from_registry: Vec<_> = Collection::ALL + .into_iter() + .flat_map(Collection::all) + .map(|p| p.id) + .collect(); + let from_global: Vec<_> = all().map(|p| p.id).collect(); + + assert_eq!(from_registry, from_global); + } + + #[test] + fn collection_get_handles_singletons_and_numbered_puzzles() { + assert_eq!( + Collection::parse("gsmg").unwrap().get("").unwrap().id, + "gsmg" + ); + assert_eq!( + Collection::parse("bitaps").unwrap().get("").unwrap().id, + "bitaps" + ); + assert_eq!( + Collection::parse("b1000").unwrap().get("66").unwrap().id, + "b1000/66" + ); + } + + #[test] + fn collection_get_rejects_singleton_suffixes() { + assert!(matches!( + Collection::parse("gsmg").unwrap().get("extra"), + Err(Error::NotFound(id)) if id == "gsmg/extra" + )); + assert!(matches!( + Collection::parse("bitaps").unwrap().get("extra"), + Err(Error::NotFound(id)) if id == "bitaps/extra" + )); + } + + #[test] + fn global_get_rejects_singleton_slash_ids() { + assert!(matches!( + get("gsmg/extra"), + Err(Error::NotFound(id)) if id == "gsmg/extra" + )); + assert!(matches!( + get("bitaps/extra"), + Err(Error::NotFound(id)) if id == "bitaps/extra" + )); + assert!(matches!( + get("gsmg/"), + Err(Error::NotFound(id)) if id == "gsmg/" + )); + assert!(matches!( + get("bitaps/"), + Err(Error::NotFound(id)) if id == "bitaps/" + )); + } + + #[test] + fn global_get_rejects_extra_path_segments() { + assert!(matches!( + get("b1000/66/extra"), + Err(Error::NotFound(id)) if id == "b1000/66/extra" + )); + assert!(matches!( + get("hash_collision/sha256/extra"), + Err(Error::NotFound(id)) if id == "hash_collision/sha256/extra" + )); + } +}