bpi-rs 0.2.1

Bilibili API client library for Rust
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::{BpiError, BpiResult};

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FieldValueKind {
    Object,
    Array,
    String,
    Number,
    Bool,
    Null,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldStat {
    pub path: String,
    pub count: usize,
    pub value_kinds: BTreeSet<FieldValueKind>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldAuditReport {
    pub files: usize,
    pub fields: Vec<FieldStat>,
}

pub fn audit_contract_fields(root: impl AsRef<Path>) -> BpiResult<FieldAuditReport> {
    let mut stats = BTreeMap::<String, FieldStat>::new();
    let mut files = 0;

    for path in json_files(root.as_ref())? {
        let bytes = fs::read(&path)
            .map_err(|err| BpiError::parse(format!("failed to read {}: {err}", path.display())))?;
        let value: serde_json::Value = serde_json::from_slice(&bytes)
            .map_err(|err| BpiError::parse(format!("failed to parse {}: {err}", path.display())))?;

        files += 1;
        collect_fields(&value, "", &mut stats);
    }

    let mut fields = stats.into_values().collect::<Vec<_>>();
    fields.sort_by(|left, right| {
        right
            .count
            .cmp(&left.count)
            .then_with(|| left.path.cmp(&right.path))
    });

    Ok(FieldAuditReport { files, fields })
}

fn json_files(root: &Path) -> BpiResult<Vec<PathBuf>> {
    let mut files = Vec::new();
    collect_json_files(root, &mut files)?;
    files.sort();
    Ok(files)
}

fn collect_json_files(path: &Path, files: &mut Vec<PathBuf>) -> BpiResult<()> {
    if path.is_file() {
        if path.extension().and_then(|value| value.to_str()) == Some("json") {
            files.push(path.to_path_buf());
        }
        return Ok(());
    }

    if !path.is_dir() {
        return Err(BpiError::invalid_parameter(
            "path",
            "field audit path must be a JSON file or directory",
        ));
    }

    let entries = fs::read_dir(path)
        .map_err(|err| BpiError::parse(format!("failed to read {}: {err}", path.display())))?;
    for entry in entries {
        let entry = entry.map_err(|err| BpiError::parse(err.to_string()))?;
        collect_json_files(&entry.path(), files)?;
    }

    Ok(())
}

fn collect_fields(value: &serde_json::Value, path: &str, stats: &mut BTreeMap<String, FieldStat>) {
    match value {
        serde_json::Value::Object(map) => {
            for (key, child) in map {
                let child_path = if path.is_empty() {
                    format!("$.{key}")
                } else {
                    format!("{path}.{key}")
                };
                record_field(&child_path, child, stats);
                collect_fields(child, &child_path, stats);
            }
        }
        serde_json::Value::Array(items) => {
            let child_path = format!("{path}[]");
            record_kind(&child_path, FieldValueKind::Array, stats);
            for item in items {
                collect_fields(item, &child_path, stats);
            }
        }
        _ => {}
    }
}

fn record_field(path: &str, value: &serde_json::Value, stats: &mut BTreeMap<String, FieldStat>) {
    record_kind(path, value_kind(value), stats);
}

fn record_kind(path: &str, kind: FieldValueKind, stats: &mut BTreeMap<String, FieldStat>) {
    let stat = stats.entry(path.to_string()).or_insert_with(|| FieldStat {
        path: path.to_string(),
        count: 0,
        value_kinds: BTreeSet::new(),
    });
    stat.count += 1;
    stat.value_kinds.insert(kind);
}

fn value_kind(value: &serde_json::Value) -> FieldValueKind {
    match value {
        serde_json::Value::Object(_) => FieldValueKind::Object,
        serde_json::Value::Array(_) => FieldValueKind::Array,
        serde_json::Value::String(_) => FieldValueKind::String,
        serde_json::Value::Number(_) => FieldValueKind::Number,
        serde_json::Value::Bool(_) => FieldValueKind::Bool,
        serde_json::Value::Null => FieldValueKind::Null,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn field_audit_collapses_array_indices() {
        let value = serde_json::json!({
            "data": {
                "list": [
                    { "owner": { "mid": 1, "name": "a" } },
                    { "owner": { "mid": 2, "name": "b" } }
                ]
            }
        });
        let mut stats = BTreeMap::new();

        collect_fields(&value, "", &mut stats);

        assert_eq!(stats["$.data.list[].owner.mid"].count, 2);
        assert_eq!(stats["$.data.list[].owner.name"].count, 2);
        assert!(
            stats["$.data.list[].owner.mid"]
                .value_kinds
                .contains(&FieldValueKind::Number)
        );
    }
}