use mineral_config_macros::{config_section, lua_enum};
use rustc_hash::FxHashMap;
use serde::Deserialize;
const CORE_TABLES: [&str; 2] = ["plays", "sessions"];
fn deserialize_collect<'de, D>(deserializer: D) -> Result<FxHashMap<String, bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
let map = FxHashMap::<String, bool>::deserialize(deserializer)?;
for core in CORE_TABLES {
if map.contains_key(core) {
return Err(serde::de::Error::custom(format!(
"collect 不能覆盖 core 本体 {core:?}(关闭播放 / 会话请用 level = \"off\")"
)));
}
}
Ok(map)
}
#[lua_enum]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StatsLevel {
Off,
Core,
Full,
}
#[lua_enum]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SearchQueryMode {
Raw,
Hashed,
Off,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RetentionDays {
Forever,
Days(u32),
}
impl<'de> Deserialize<'de> for RetentionDays {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
deserializer.deserialize_any(RetentionVisitor)
}
}
struct RetentionVisitor;
impl serde::de::Visitor<'_> for RetentionVisitor {
type Value = RetentionDays;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("false(永久)或正整数天数")
}
fn visit_bool<E>(self, value: bool) -> Result<RetentionDays, E>
where
E: serde::de::Error,
{
if value {
Err(E::custom("retention_days 只接受 false(永久),true 无意义"))
} else {
Ok(RetentionDays::Forever)
}
}
fn visit_u64<E>(self, value: u64) -> Result<RetentionDays, E>
where
E: serde::de::Error,
{
days_from(value.try_into().ok())
}
fn visit_i64<E>(self, value: i64) -> Result<RetentionDays, E>
where
E: serde::de::Error,
{
days_from(u32::try_from(value).ok())
}
}
fn days_from<E>(days: Option<u32>) -> Result<RetentionDays, E>
where
E: serde::de::Error,
{
match days {
Some(0) | None => Err(E::custom("retention_days 不能为 0(要永久请用 false)")),
Some(n) => Ok(RetentionDays::Days(n)),
}
}
#[config_section]
pub struct ReportConfig {
min_listen_secs: u64,
top_limit: usize,
}
#[config_section]
pub struct StatsConfig {
level: StatsLevel,
#[serde(deserialize_with = "deserialize_collect")]
collect: FxHashMap<String, bool>,
search_queries: SearchQueryMode,
#[serde(deserialize_with = "deserialize_source_list")]
#[lua_type("mineral.SourceName[]")]
exclude_sources: Vec<String>,
session_gap_minutes: u64,
retention_days: RetentionDays,
report: ReportConfig,
}
fn deserialize_source_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Array(items) => items
.into_iter()
.map(|item| match item {
serde_json::Value::String(s) => Ok(s),
other => Err(serde::de::Error::custom(format!(
"列表元素须为字符串,得到 `{other}`"
))),
})
.collect(),
serde_json::Value::Object(map) if map.is_empty() => Ok(Vec::new()),
other => Err(serde::de::Error::custom(format!(
"期望字符串数组(空表亦可),得到 `{other}`"
))),
}
}
#[cfg(test)]
mod tests {
use super::{RetentionDays, SearchQueryMode, StatsLevel};
#[test]
fn level_and_mode_parse_lowercase() -> color_eyre::Result<()> {
assert_eq!(
serde_json::from_str::<StatsLevel>("\"full\"")?,
StatsLevel::Full
);
assert_eq!(
serde_json::from_str::<StatsLevel>("\"off\"")?,
StatsLevel::Off
);
assert_eq!(
serde_json::from_str::<SearchQueryMode>("\"hashed\"")?,
SearchQueryMode::Hashed
);
Ok(())
}
#[test]
fn retention_false_is_forever() -> color_eyre::Result<()> {
assert_eq!(
serde_json::from_str::<RetentionDays>("false")?,
RetentionDays::Forever
);
Ok(())
}
#[test]
fn retention_positive_is_days() -> color_eyre::Result<()> {
assert_eq!(
serde_json::from_str::<RetentionDays>("365")?,
RetentionDays::Days(365)
);
Ok(())
}
#[test]
fn retention_zero_and_true_rejected() {
assert!(serde_json::from_str::<RetentionDays>("0").is_err());
assert!(serde_json::from_str::<RetentionDays>("true").is_err());
}
#[test]
fn collect_rejects_core_table_overrides() {
let ok = super::deserialize_collect(&mut serde_json::Deserializer::from_str(
r#"{"searches": false, "downloads": true}"#,
));
assert!(ok.is_ok(), "普通事件 kind 键应放行");
for bad in [r#"{"plays": false}"#, r#"{"sessions": true}"#] {
let got = super::deserialize_collect(&mut serde_json::Deserializer::from_str(bad));
assert!(got.is_err(), "core 本体覆盖应拒:{bad}");
}
}
}