bms_table/lib.rs
1//! BMS difficulty table parsing
2//!
3//! Provides building a complete BMS difficulty table data structure from header JSON and chart data JSON,
4//! covering the header, courses, trophies, and chart items.
5//! Also includes HTML parsing (via [`htmlparser`](https://docs.rs/htmlparser)) for extracting the bmstable
6//! header URL from a page.
7//!
8//! # Features
9//!
10//! - Parse header JSON into [`BmsTableHeader`], preserving unrecognized fields in `extra` for forward compatibility;
11//! - Parse chart data into [`BmsTableData`], supporting a plain array of [`ChartItem`] structure;
12//! - Course `md5`/`sha256` shorthand lists are preserved as independent fields; use [`CourseInfo::all_charts`] for a merged view;
13//! - Extract the header JSON URL from HTML `<meta name="bmstable">` (zero-copy, returns `&str`).
14//!
15//! # Usage
16//!
17//! ```rust
18//! # fn main() -> Result<(), serde_json::Error> {
19//! use bms_table::{BmsTable, BmsTableHeader, BmsTableData};
20//!
21//! let header_json = r#"{ "name": "Test", "symbol": "t", "data_url": "charts.json", "course": [], "level_order": [] }"#;
22//! let data_json = r#"[]"#;
23//! let header: BmsTableHeader = serde_json::from_str(header_json)?;
24//! let data: BmsTableData = serde_json::from_str(data_json)?;
25//! let table = BmsTable::new(header, data);
26//! assert!(table.header.course.flatten().is_empty());
27//! # Ok(())
28//! # }
29//! ```
30
31mod de;
32mod error;
33
34/// Re-export for convenience.
35pub use crate::error::BmsTableError;
36
37use std::collections::BTreeMap;
38use std::ops::{Deref, DerefMut};
39
40use htmlparser::{Token, Tokenizer};
41use serde::{Deserialize, Serialize};
42use serde_json::Value;
43
44use crate::de::{deserialize_level, deserialize_level_order};
45
46// Data types
47
48/// Top-level BMS difficulty table data structure.
49///
50/// Packs header metadata and chart data together to simplify passing and use in applications.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52#[non_exhaustive]
53pub struct BmsTable {
54 /// Header information and extra fields
55 pub header: BmsTableHeader,
56 /// Table data containing the chart list
57 pub data: BmsTableData,
58}
59
60impl BmsTable {
61 /// Creates a new `BmsTable` from its header and data.
62 #[must_use]
63 pub const fn new(header: BmsTableHeader, data: BmsTableData) -> Self {
64 Self { header, data }
65 }
66}
67
68/// BMS header information.
69///
70/// Strictly parses common fields and preserves unrecognized fields in `extra` for forward compatibility.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72#[non_exhaustive]
73pub struct BmsTableHeader {
74 /// Table name, e.g. "Satellite"
75 pub name: String,
76 /// Table symbol, e.g. "sl"
77 pub symbol: String,
78 /// URL of chart data file (preserves the original string from header JSON)
79 pub data_url: String,
80 /// Tag label text; use [`effective_tag`](BmsTableHeader::effective_tag) to
81 /// get the spec-mandated fallback to `symbol` when this is `None`.
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub tag: Option<String>,
84 /// Play mode hint; same semantics as bmson's `mode_hint`
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub mode: Option<String>,
87 /// Course information, preserving the original JSON nesting shape.
88 ///
89 /// Supports flat arrays (`"course": [{...}]`) and arbitrarily nested arrays
90 /// (`"course": [[{...}]]`, `"course": [[{...}], [{...}]]`, etc.).
91 /// Deserialized as a [`CourseGroup`] tree.
92 #[serde(default)]
93 pub course: CourseGroup,
94 /// Difficulty level order containing numbers and strings
95 #[serde(default, deserialize_with = "deserialize_level_order")]
96 pub level_order: Vec<String>,
97 /// Extra data (unrecognized fields from header JSON)
98 #[serde(flatten)]
99 pub extra: BTreeMap<String, Value>,
100}
101
102impl BmsTableHeader {
103 /// Creates a new `BmsTableHeader` with the given required fields and defaults for everything else.
104 #[must_use]
105 pub const fn new(name: String, symbol: String, data_url: String) -> Self {
106 Self {
107 name,
108 symbol,
109 data_url,
110 tag: None,
111 mode: None,
112 course: CourseGroup::Courses(Vec::new()),
113 level_order: Vec::new(),
114 extra: BTreeMap::new(),
115 }
116 }
117
118 /// Returns the index of a level in `level_order`, or `None` if not found.
119 ///
120 /// This is useful for comparing difficulty levels: a lower index means an easier level.
121 ///
122 /// **Note:** This performs a linear scan (`O(n)`) on each call. For repeated
123 /// lookups across many chart items, consider building a
124 /// `HashMap<&str, usize>` from `level_order` once.
125 ///
126 /// # Example
127 ///
128 /// ```rust
129 /// # use bms_table::BmsTableHeader;
130 /// let mut header = BmsTableHeader::new(
131 /// "Test".into(),
132 /// "t".into(),
133 /// "d.json".into(),
134 /// );
135 /// header.level_order = vec!["1".into(), "2".into(), "3".into()];
136 /// assert_eq!(header.level_index("2"), Some(1));
137 /// assert_eq!(header.level_index("4"), None);
138 /// ```
139 #[must_use]
140 pub fn level_index(&self, level: &str) -> Option<usize> {
141 self.level_order.iter().position(|l| l == level)
142 }
143
144 /// Returns the effective tag, falling back to `symbol` when `tag` is `None`.
145 ///
146 /// Per the BMS difficulty table spec, the `tag` field should fall back
147 /// to `symbol` when absent. This method implements that behavior.
148 ///
149 /// # Example
150 ///
151 /// ```rust
152 /// # use bms_table::BmsTableHeader;
153 /// let header = BmsTableHeader::new("Table".into(), "sl".into(), "d.json".into());
154 /// assert_eq!(header.effective_tag(), "sl");
155 /// ```
156 #[must_use]
157 pub fn effective_tag(&self) -> &str {
158 self.tag.as_deref().unwrap_or(&self.symbol)
159 }
160}
161
162/// BMS table data.
163///
164/// Wraps the chart array (`[...]`). The input JSON is expected to be a plain array of [`ChartItem`]
165/// objects; the `{ "charts": [...] }` wrapper form is **not** supported.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(transparent)]
168#[non_exhaustive]
169pub struct BmsTableData(pub Vec<ChartItem>);
170
171impl BmsTableData {
172 /// Creates a new `BmsTableData` with the given chart list.
173 #[must_use]
174 pub const fn new(charts: Vec<ChartItem>) -> Self {
175 Self(charts)
176 }
177}
178
179impl Deref for BmsTableData {
180 type Target = Vec<ChartItem>;
181
182 fn deref(&self) -> &Self::Target {
183 &self.0
184 }
185}
186
187impl DerefMut for BmsTableData {
188 fn deref_mut(&mut self) -> &mut Self::Target {
189 &mut self.0
190 }
191}
192
193impl Default for BmsTableData {
194 fn default() -> Self {
195 Self::new(Vec::new())
196 }
197}
198
199/// Recursive course tree supporting arbitrary nesting depth.
200///
201/// - [`Courses`][CourseGroup::Courses] — a leaf node containing a list of [`CourseInfo`] entries
202/// - [`SubGroups`][CourseGroup::SubGroups] — a branch node containing nested sub-groups
203///
204/// Together with `CourseGroup` as the `course` field type, this preserves
205/// the original JSON nesting shape round-trip:
206///
207/// | JSON | Rust |
208/// |---|---|
209/// | `"course": []` | `Courses(vec![])` |
210/// | `"course": [{...}]` | `Courses(vec![CourseInfo])` |
211/// | `"course": [[{...}]]` | `SubGroups(vec![Courses(vec![CourseInfo])])` |
212/// | `"course": [[{...}], [{...}]]` | `SubGroups(vec![Courses(..), Courses(..)])` |
213/// | `"course": [[[{...}]]]` | `SubGroups(vec![SubGroups(vec![Courses(..)])])` |
214///
215/// This enum is `#[non_exhaustive]` — use [`flatten`](CourseGroup::flatten) or
216/// [`into_flattened`](CourseGroup::into_flattened) to access all [`CourseInfo`]
217/// entries without matching on variants directly.
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
219#[serde(untagged)]
220#[non_exhaustive]
221pub enum CourseGroup {
222 /// A leaf node containing a list of course entries.
223 Courses(Vec<CourseInfo>),
224 /// A branch node containing nested sub-groups.
225 SubGroups(Vec<CourseGroup>),
226}
227
228impl Default for CourseGroup {
229 fn default() -> Self {
230 Self::Courses(Vec::new())
231 }
232}
233
234impl CourseGroup {
235 /// Flattens all [`CourseInfo`] references in this sub-tree.
236 #[must_use]
237 pub fn flatten(&self) -> Vec<&CourseInfo> {
238 match self {
239 Self::Courses(v) => v.iter().collect(),
240 Self::SubGroups(v) => v.iter().flat_map(CourseGroup::flatten).collect(),
241 }
242 }
243
244 /// Flattens this sub-tree into owned [`CourseInfo`] values.
245 #[must_use]
246 pub fn into_flattened(self) -> Vec<CourseInfo> {
247 match self {
248 Self::Courses(v) => v,
249 Self::SubGroups(v) => v
250 .into_iter()
251 .flat_map(CourseGroup::into_flattened)
252 .collect(),
253 }
254 }
255}
256
257impl From<CourseInfo> for CourseGroup {
258 fn from(info: CourseInfo) -> Self {
259 Self::Courses(vec![info])
260 }
261}
262
263/// Course information.
264///
265/// Describes a course's name, constraints, trophies and chart set.
266/// Charts are specified via three independent fields that can coexist:
267/// `charts` (full objects), `md5` (hash shorthand), and `sha256` (hash shorthand).
268/// All three are preserved for round-trip fidelity.
269/// Use [`CourseInfo::all_charts`] for a merged view.
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271#[non_exhaustive]
272pub struct CourseInfo {
273 /// Course name, e.g. "Satellite Skill Analyzer 2nd sl0"
274 pub name: String,
275 /// Constraint list, e.g. `["grade_mirror", "gauge_lr2", "ln"]`
276 #[serde(default)]
277 pub constraint: Vec<String>,
278 /// List of trophies, defining requirements for different ranks
279 #[serde(default)]
280 pub trophy: Vec<Trophy>,
281 /// Full chart objects from the `charts` JSON array
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
283 pub charts: Vec<ChartItem>,
284 /// MD5 hash shorthand list, expanded by [`all_charts`](CourseInfo::all_charts) with `level = "0"`
285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
286 pub md5: Vec<String>,
287 /// SHA256 hash shorthand list, expanded by [`all_charts`](CourseInfo::all_charts) with `level = "0"`
288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
289 pub sha256: Vec<String>,
290 /// Extra data (unrecognized fields from course JSON)
291 #[serde(flatten)]
292 pub extra: BTreeMap<String, Value>,
293}
294
295impl CourseInfo {
296 /// Creates a new `CourseInfo` with the given name and all other fields empty.
297 #[must_use]
298 pub const fn new(name: String) -> Self {
299 Self {
300 name,
301 constraint: Vec::new(),
302 trophy: Vec::new(),
303 charts: Vec::new(),
304 md5: Vec::new(),
305 sha256: Vec::new(),
306 extra: BTreeMap::new(),
307 }
308 }
309
310 /// Merges all chart sources in order: `charts` → `md5` → `sha256`.
311 ///
312 /// Hash shorthand entries (`md5`, `sha256`) are expanded into [`ChartItem`]s
313 /// with `level` defaulting to `"0"` per the BMS difficulty table spec.
314 #[must_use]
315 pub fn all_charts(&self) -> Vec<ChartItem> {
316 fn from_md5(hash: &str) -> ChartItem {
317 ChartItem {
318 md5: Some(hash.to_owned()),
319 level: "0".to_owned(),
320 ..ChartItem::empty()
321 }
322 }
323 fn from_sha256(hash: &str) -> ChartItem {
324 ChartItem {
325 sha256: Some(hash.to_owned()),
326 level: "0".to_owned(),
327 ..ChartItem::empty()
328 }
329 }
330
331 let mut result = self.charts.clone();
332 result.extend(self.md5.iter().map(|h| from_md5(h)));
333 result.extend(self.sha256.iter().map(|h| from_sha256(h)));
334 result
335 }
336}
337
338/// Chart data item.
339///
340/// Describes metadata and resource links for a single BMS file.
341///
342/// Only the most commonly used spec-defined fields (`md5`, `sha256`, `level`,
343/// `title`, `artist`, `url`, `url_diff`, `comment`) are exposed as first-class
344/// fields. Other spec-defined optional fields (such as `name_diff`, `url_pack`,
345/// `name_pack`, `org_md5`, `mode`, `ipfs`, `ipfs_diff`, `lr2_bmsid`, etc.) are
346/// **not** individually promoted — they are preserved via [`extra`](ChartItem::extra)
347/// for forward compatibility. This keeps the struct lean while remaining
348/// fully compatible with all real-world tables.
349#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
350#[non_exhaustive]
351pub struct ChartItem {
352 /// Difficulty level, e.g. "0"
353 ///
354 /// Defaults to `"0"` when `null` or absent in JSON, per the BMS
355 /// difficulty table spec.
356 #[serde(
357 default = "crate::de::default_level",
358 deserialize_with = "deserialize_level"
359 )]
360 pub level: String,
361 /// MD5 hash of the file
362 #[serde(skip_serializing_if = "Option::is_none")]
363 pub md5: Option<String>,
364 /// SHA256 hash of the file
365 #[serde(skip_serializing_if = "Option::is_none")]
366 pub sha256: Option<String>,
367 /// Song title
368 #[serde(skip_serializing_if = "Option::is_none")]
369 pub title: Option<String>,
370 /// Artist name
371 #[serde(skip_serializing_if = "Option::is_none")]
372 pub artist: Option<String>,
373 /// File download URL
374 #[serde(skip_serializing_if = "Option::is_none")]
375 pub url: Option<String>,
376 /// Differential file download URL (optional)
377 #[serde(skip_serializing_if = "Option::is_none")]
378 pub url_diff: Option<String>,
379 /// Comment text
380 #[serde(skip_serializing_if = "Option::is_none")]
381 pub comment: Option<String>,
382 /// Extra data (unrecognized fields)
383 #[serde(flatten)]
384 pub extra: BTreeMap<String, Value>,
385}
386
387impl ChartItem {
388 /// Creates a zero-alloc empty `ChartItem` with an empty level string.
389 ///
390 /// Unlike [`new`][Self::new] and [`default`][Default::default], this constructor
391 /// does **not** allocate — the level is an empty `String` (not `"0"`).
392 /// Prefer [`new`][Self::new] or [`default`][Default::default] when you need the
393 /// spec-mandated default level.
394 #[must_use]
395 pub const fn empty() -> Self {
396 Self {
397 level: String::new(),
398 md5: None,
399 sha256: None,
400 title: None,
401 artist: None,
402 url: None,
403 url_diff: None,
404 comment: None,
405 extra: BTreeMap::new(),
406 }
407 }
408
409 /// Creates a new `ChartItem` with the given level and all other fields set to defaults.
410 #[must_use]
411 pub const fn new(level: String) -> Self {
412 Self {
413 level,
414 md5: None,
415 sha256: None,
416 title: None,
417 artist: None,
418 url: None,
419 url_diff: None,
420 comment: None,
421 extra: BTreeMap::new(),
422 }
423 }
424}
425
426impl Default for ChartItem {
427 fn default() -> Self {
428 Self::new(crate::de::default_level())
429 }
430}
431
432/// Trophy information.
433///
434/// Defines conditions to achieve specific trophies, including maximum miss rate and minimum score rate.
435#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
436#[non_exhaustive]
437pub struct Trophy {
438 /// Trophy name, e.g. "silvermedal" or "goldmedal"
439 pub name: String,
440 /// Maximum miss rate (percent), e.g. 5.0 means at most 5% miss rate
441 pub missrate: f64,
442 /// Minimum score rate (percent), e.g. 70.0 means at least 70% score rate
443 pub scorerate: f64,
444}
445
446impl Trophy {
447 /// Creates a new `Trophy` with the given name and rate thresholds.
448 #[must_use]
449 pub const fn new(name: String, missrate: f64, scorerate: f64) -> Self {
450 Self {
451 name,
452 missrate,
453 scorerate,
454 }
455 }
456}
457
458/// BMS difficulty table list item.
459///
460/// Represents the basic information of a difficulty table in a list. `name`, `symbol`, and `url` are
461/// the core fields; other fields such as `tag1`, `tag2`, `comment`, `date`, `state`, and `tag_order`
462/// are collected into [`extra`](BmsTableInfo::extra).
463#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
464#[non_exhaustive]
465pub struct BmsTableInfo {
466 /// Table name, e.g. ".WAS Difficulty Table"
467 pub name: String,
468 /// Table symbol, e.g. "." or "\[F\]"
469 pub symbol: String,
470 /// Table URL
471 pub url: url::Url,
472 /// Extra fields collection (stores all data except required fields)
473 #[serde(flatten)]
474 pub extra: BTreeMap<String, Value>,
475}
476
477impl BmsTableInfo {
478 /// Creates a new `BmsTableInfo` with the given required fields and an empty `extra`.
479 #[must_use]
480 pub const fn new(name: String, symbol: String, url: url::Url) -> Self {
481 Self {
482 name,
483 symbol,
484 url,
485 extra: BTreeMap::new(),
486 }
487 }
488}
489
490/// Wrapper type for the list of BMS difficulty tables.
491///
492/// Transparently serialized as an array: serialization/deserialization behaves the same as the internal `Vec<BmsTableInfo>`, resulting in a JSON array rather than an object.
493#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(transparent)]
495#[non_exhaustive]
496pub struct BmsTableList(pub Vec<BmsTableInfo>);
497
498impl BmsTableList {
499 /// Creates a new `BmsTableList` with the given entries.
500 #[must_use]
501 pub const fn new(entries: Vec<BmsTableInfo>) -> Self {
502 Self(entries)
503 }
504}
505
506impl Deref for BmsTableList {
507 type Target = Vec<BmsTableInfo>;
508
509 fn deref(&self) -> &Self::Target {
510 &self.0
511 }
512}
513
514impl DerefMut for BmsTableList {
515 fn deref_mut(&mut self) -> &mut Self::Target {
516 &mut self.0
517 }
518}
519
520// HTML parsing helper
521
522/// HTML parsing for BMS difficulty tables.
523///
524/// Provides extraction of the header JSON URL from
525/// `<meta name="bmstable" content="...">` or
526/// `<meta property="bmstable" content="...">` in HTML page content.
527///
528/// The implementation uses the `htmlparser` tokenizer under the hood,
529/// returning a **borrowed** slice of the original input where possible.
530/// Attribute values containing HTML entities are decoded into the tokenizer's
531/// internal buffer (heap-allocated), which borrows the input string, so the
532/// returned `&str` lifetime remains tied to the original input.
533/// Tag and attribute names are matched case-insensitively.
534/// Content inside HTML comments is naturally ignored by the tokenizer.
535pub struct BmsTableHtml;
536
537impl BmsTableHtml {
538 /// Extract the header JSON URL from `<meta name="bmstable" content="...">` in HTML.
539 ///
540 /// Returns a borrowed `&str` referencing the original input — no allocation.
541 /// Scans `<meta>` tags looking for elements with `name="bmstable"` or
542 /// `property="bmstable"` and reads their `content` attribute.
543 ///
544 /// # Errors
545 ///
546 /// Returns [`BmsTableError::MetaTagNotFound`] when the target tag is not
547 /// found or `content` is empty.
548 ///
549 /// # Example
550 ///
551 /// ```rust
552 /// # use bms_table::BmsTableHtml;
553 /// let html = r#"
554 /// <!DOCTYPE html>
555 /// <html>
556 /// <head>
557 /// <meta name="bmstable" content="header.json">
558 /// </head>
559 /// <body></body>
560 /// </html>
561 /// "#;
562 /// let url = BmsTableHtml::extract_url(html).unwrap();
563 /// assert_eq!(url, "header.json");
564 /// ```
565 pub fn extract_url<'a>(html_content: &'a str) -> Result<&'a str, BmsTableError> {
566 let mut in_meta = false;
567 let mut is_bmstable = false;
568 let mut content: Option<&'a str> = None;
569 let mut tokenizer_error: Option<String> = None;
570
571 for token in Tokenizer::from(html_content) {
572 let token = match token {
573 Ok(t) => t,
574 Err(e) => {
575 if tokenizer_error.is_none() {
576 tokenizer_error = Some(e.to_string());
577 }
578 continue;
579 }
580 };
581 match token {
582 Token::ElementStart { local, .. } => {
583 in_meta = local.as_str().eq_ignore_ascii_case("meta");
584 if in_meta {
585 is_bmstable = false;
586 content = None;
587 }
588 }
589 Token::Attribute { local, value, .. } if in_meta => {
590 let name = local.as_str();
591 if name.eq_ignore_ascii_case("name") || name.eq_ignore_ascii_case("property") {
592 is_bmstable =
593 value.is_some_and(|v| v.as_str().eq_ignore_ascii_case("bmstable"));
594 } else if name.eq_ignore_ascii_case("content") {
595 content = value.map(|v| v.as_str());
596 }
597 }
598 Token::ElementEnd { .. } if in_meta => {
599 if is_bmstable
600 && let Some(c) = content
601 && !c.is_empty()
602 {
603 return Ok(c);
604 }
605 in_meta = false;
606 }
607 _ => {}
608 }
609 }
610
611 Err(tokenizer_error.map_or(
612 BmsTableError::MetaTagNotFound,
613 BmsTableError::TokenizerError,
614 ))
615 }
616}