Skip to main content

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}