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 for extracting the bmstable header URL from a page.
6//!
7//! # Features
8//!
9//! - Parse header JSON into [`BmsTableHeader`], preserving unrecognized fields in `extra` for forward compatibility;
10//! - Parse chart data into [`BmsTableData`], supporting a plain array of [`ChartItem`] structure;
11//! - Courses automatically convert `md5`/`sha256` lists into chart items, filling missing `level` with "0";
12//! - Extract the header JSON URL from HTML `<meta name="bmstable">`.
13//!
14//! # Usage
15//!
16//! ```rust
17//! # fn main() -> Result<(), serde_json::Error> {
18//! use bms_table::{BmsTable, BmsTableHeader, BmsTableData, CourseGroup};
19//!
20//! let header_json = r#"{ "name": "Test", "symbol": "t", "data_url": "charts.json", "course": [], "level_order": [] }"#;
21//! let data_json = r#"[]"#;
22//! let header: BmsTableHeader = serde_json::from_str(header_json)?;
23//! let data: BmsTableData = serde_json::from_str(data_json)?;
24//! let table = BmsTable { header, data };
25//! assert!(table.header.course.flatten().is_empty());
26//! # Ok(())
27//! # }
28//! ```
29
30#![warn(missing_docs)]
31#![warn(clippy::must_use_candidate)]
32#![deny(rustdoc::broken_intra_doc_links)]
33
34mod de;
35
36use std::collections::BTreeMap;
37
38use anyhow::{Result, anyhow};
39use scraper::{Html, Selector};
40use serde::{Deserialize, Serialize};
41use serde_json::Value;
42
43use crate::de::{de_numstring, deserialize_level_order};
44
45// Data types
46
47/// Top-level BMS difficulty table data structure.
48///
49/// Packs header metadata and chart data together to simplify passing and use in applications.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct BmsTable {
52 /// Header information and extra fields
53 pub header: BmsTableHeader,
54 /// Table data containing the chart list
55 pub data: BmsTableData,
56}
57
58impl BmsTable {
59 /// Creates a new `BmsTable` from its header and data.
60 #[must_use]
61 pub const fn new(header: BmsTableHeader, data: BmsTableData) -> Self {
62 Self { header, data }
63 }
64}
65
66/// BMS header information.
67///
68/// Strictly parses common fields and preserves unrecognized fields in `extra` for forward compatibility.
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70pub struct BmsTableHeader {
71 /// Table name, e.g. "Satellite"
72 pub name: String,
73 /// Table symbol, e.g. "sl"
74 pub symbol: String,
75 /// URL of chart data file (preserves the original string from header JSON)
76 pub data_url: String,
77 /// Tag label text; falls back to `symbol` when absent
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub tag: Option<String>,
80 /// Play mode hint; same semantics as bmson's `mode_hint`
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub mode: Option<String>,
83 /// Course information, preserving the original JSON nesting shape.
84 ///
85 /// Supports flat arrays (`"course": [{...}]`) and arbitrarily nested arrays
86 /// (`"course": [[{...}]]`, `"course": [[{...}], [{...}]]`, etc.).
87 /// Deserialized as a [`CourseGroup`] tree.
88 #[serde(default)]
89 pub course: CourseGroup,
90 /// Difficulty level order containing numbers and strings
91 #[serde(default, deserialize_with = "deserialize_level_order")]
92 pub level_order: Vec<String>,
93 /// Extra data (unrecognized fields from header JSON)
94 #[serde(flatten)]
95 pub extra: BTreeMap<String, Value>,
96}
97
98impl BmsTableHeader {
99 /// Creates a new `BmsTableHeader` with the given required fields and defaults for everything else.
100 #[must_use]
101 pub const fn new(name: String, symbol: String, data_url: String) -> Self {
102 Self {
103 name,
104 symbol,
105 data_url,
106 tag: None,
107 mode: None,
108 course: CourseGroup::Courses(Vec::new()),
109 level_order: Vec::new(),
110 extra: BTreeMap::new(),
111 }
112 }
113}
114
115/// BMS table data.
116///
117/// Contains only the chart array. Parsing supports both a plain array and `{ charts: [...] }` input forms.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(transparent)]
120pub struct BmsTableData {
121 /// Charts
122 pub charts: Vec<ChartItem>,
123}
124
125/// Recursive course tree supporting arbitrary nesting depth.
126///
127/// - [`Courses`][CourseGroup::Courses] — a leaf node containing a list of [`CourseInfo`] entries
128/// - [`SubGroups`][CourseGroup::SubGroups] — a branch node containing nested sub-groups
129///
130/// Together with `CourseGroup` as the `course` field type, this preserves
131/// the original JSON nesting shape round-trip:
132///
133/// | JSON | Rust |
134/// |---|---|
135/// | `"course": []` | `Courses(vec![])` |
136/// | `"course": [{...}]` | `Courses(vec![CourseInfo])` |
137/// | `"course": [[{...}]]` | `SubGroups(vec![Courses(vec![CourseInfo])])` |
138/// | `"course": [[{...}], [{...}]]` | `SubGroups(vec![Courses(..), Courses(..)])` |
139/// | `"course": [[[{...}]]]` | `SubGroups(vec![SubGroups(vec![Courses(..)])])` |
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(untagged)]
142pub enum CourseGroup {
143 /// A leaf node containing a list of course entries.
144 Courses(Vec<CourseInfo>),
145 /// A branch node containing nested sub-groups.
146 SubGroups(Vec<CourseGroup>),
147}
148
149impl Default for CourseGroup {
150 fn default() -> Self {
151 Self::Courses(Vec::new())
152 }
153}
154
155impl CourseGroup {
156 /// Flattens all [`CourseInfo`] references in this sub-tree.
157 pub fn flatten(&self) -> Vec<&CourseInfo> {
158 match self {
159 Self::Courses(v) => v.iter().collect(),
160 Self::SubGroups(v) => v.iter().flat_map(CourseGroup::flatten).collect(),
161 }
162 }
163}
164
165impl From<CourseInfo> for CourseGroup {
166 fn from(info: CourseInfo) -> Self {
167 Self::Courses(vec![info])
168 }
169}
170
171/// Course information.
172///
173/// Describes a course's name, constraints, trophies and chart set. During parsing, `md5`/`sha256` lists are automatically converted into `ChartItem`s, and charts missing `level` are filled with default value `"0"`.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175#[serde(try_from = "crate::de::CourseInfoRaw")]
176pub struct CourseInfo {
177 /// Course name, e.g. "Satellite Skill Analyzer 2nd sl0"
178 pub name: String,
179 /// Constraint list, e.g. ["`grade_mirror`", "`gauge_lr2`", "ln"]
180 #[serde(default)]
181 pub constraint: Vec<String>,
182 /// List of trophies, defining requirements for different ranks
183 #[serde(default)]
184 pub trophy: Vec<Trophy>,
185 /// List of charts included in the course
186 #[serde(default)]
187 pub charts: Vec<ChartItem>,
188}
189
190/// Chart data item.
191///
192/// Describes metadata and resource links for a single BMS file.
193///
194/// Non-essential fields present in the JSON (e.g. `name_diff`, `comment`, `ipfs`,
195/// `org_md5`, `subtitle`, `subartist`) are preserved via `extra` for forward
196/// compatibility.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198pub struct ChartItem {
199 /// Difficulty level, e.g. "0"
200 #[serde(default, deserialize_with = "de_numstring")]
201 pub level: String,
202 /// MD5 hash of the file
203 pub md5: Option<String>,
204 /// SHA256 hash of the file
205 pub sha256: Option<String>,
206 /// Song title
207 pub title: Option<String>,
208 /// Artist name
209 pub artist: Option<String>,
210 /// File download URL
211 pub url: Option<String>,
212 /// Differential file download URL (optional)
213 pub url_diff: Option<String>,
214 /// Extra data
215 #[serde(flatten)]
216 pub extra: BTreeMap<String, Value>,
217}
218
219impl ChartItem {
220 /// Creates a new `ChartItem` with the given level and all other fields set to defaults.
221 #[must_use]
222 pub const fn new(level: String) -> Self {
223 Self {
224 level,
225 md5: None,
226 sha256: None,
227 title: None,
228 artist: None,
229 url: None,
230 url_diff: None,
231 extra: BTreeMap::new(),
232 }
233 }
234}
235
236/// Trophy information.
237///
238/// Defines conditions to achieve specific trophies, including maximum miss rate and minimum score rate.
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct Trophy {
241 /// Trophy name, e.g. "silvermedal" or "goldmedal"
242 pub name: String,
243 /// Maximum miss rate (percent), e.g. 5.0 means at most 5% miss rate
244 pub missrate: f64,
245 /// Minimum score rate (percent), e.g. 70.0 means at least 70% score rate
246 pub scorerate: f64,
247}
248
249/// BMS difficulty table list item.
250///
251/// Represents the basic information of a difficulty table in a list. Only `name`, `symbol`, and `url` are required; other fields such as `tag1`, `tag2`, `comment`, `date`, `state`, and `tag_order` are collected into `extra`.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct BmsTableInfo {
254 /// Table name, e.g. ".WAS Difficulty Table"
255 pub name: String,
256 /// Table symbol, e.g. "." or "\[F\]"
257 pub symbol: String,
258 /// Table URL
259 pub url: url::Url,
260 /// Extra fields collection (stores all data except required fields)
261 #[serde(flatten)]
262 pub extra: BTreeMap<String, Value>,
263}
264
265/// Wrapper type for the list of BMS difficulty tables.
266///
267/// Transparently serialized as an array: serialization/deserialization behaves the same as the internal `Vec<BmsTableInfo>`, resulting in a JSON array rather than an object.
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(transparent)]
270pub struct BmsTableList {
271 /// List of entries
272 pub listes: Vec<BmsTableInfo>,
273}
274
275// HTML parsing helper
276
277/// HTML parsing for BMS difficulty tables.
278///
279/// Provides extraction of the header JSON URL from
280/// `<meta name="bmstable" content="...">` in HTML page content.
281pub struct BmsTableHtml;
282
283impl BmsTableHtml {
284 /// Extract the header JSON URL from `<meta name="bmstable" content="...">` in HTML.
285 ///
286 /// Scans `<meta>` tags looking for elements with `name="bmstable"` or `property="bmstable"`
287 /// and reads their `content` attribute.
288 ///
289 /// # Errors
290 ///
291 /// Returns an error when the target tag is not found or `content` is empty.
292 ///
293 /// # Example
294 ///
295 /// ```rust
296 /// # use bms_table::BmsTableHtml;
297 /// let html = r#"
298 /// <!DOCTYPE html>
299 /// <html>
300 /// <head>
301 /// <meta name="bmstable" content="header.json">
302 /// </head>
303 /// <body></body>
304 /// </html>
305 /// "#;
306 /// let url = BmsTableHtml::extract_url(html).unwrap();
307 /// assert_eq!(url, "header.json");
308 /// ```
309 pub fn extract_url(html_content: &str) -> Result<String> {
310 let document = Html::parse_document(html_content);
311 let meta_selector = Selector::parse("meta").map_err(|_| anyhow!("meta tag not found"))?;
312
313 for element in document.select(&meta_selector) {
314 let is_bmstable = element
315 .value()
316 .attr("name")
317 .is_some_and(|v| v.eq_ignore_ascii_case("bmstable"))
318 || element
319 .value()
320 .attr("property")
321 .is_some_and(|v| v.eq_ignore_ascii_case("bmstable"));
322 if is_bmstable
323 && let Some(content_attr) = element.value().attr("content")
324 && !content_attr.is_empty()
325 {
326 return Ok(content_attr.to_string());
327 }
328 }
329
330 Err(anyhow!("bmstable meta tag not found"))
331 }
332}