bbdown-core 0.2.0

Rust library for resolving Bilibili metadata, download plans, media, subtitles, and danmaku.
Documentation
use crate::{Error, Result};
use std::str::FromStr;

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Selection {
    Current,
    Latest,
    All,
    Episode(u64),
    Page(u32),
    Indices(IndexSelection),
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct IndexSelection {
    selectors: Vec<IndexSelector>,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum IndexSelector {
    Index(u32),
    Range { start: u32, end: u32 },
}

impl IndexSelection {
    pub fn new(selectors: impl Into<Vec<IndexSelector>>) -> Result<Self> {
        let selectors = selectors.into();
        if selectors.is_empty() {
            return Err(Error::InvalidInput(
                "index selection must contain at least one selector".to_owned(),
            ));
        }
        for selector in &selectors {
            validate_index_selector(*selector)?;
        }
        Ok(Self { selectors })
    }

    pub fn single(index: u32) -> Result<Self> {
        Self::new([IndexSelector::Index(index)])
    }

    pub fn range(start: u32, end: u32) -> Result<Self> {
        Self::new([IndexSelector::Range { start, end }])
    }

    #[must_use]
    pub fn selectors(&self) -> &[IndexSelector] {
        &self.selectors
    }

    #[must_use]
    pub fn max_index(&self) -> u32 {
        self.selectors
            .iter()
            .map(|selector| match selector {
                IndexSelector::Index(index) => *index,
                IndexSelector::Range { end, .. } => *end,
            })
            .max()
            .unwrap_or(0)
    }

    #[must_use]
    pub fn contains(&self, index: u32) -> bool {
        self.selectors
            .iter()
            .any(|selector| selector.contains(index))
    }
}

impl IndexSelector {
    #[must_use]
    pub const fn index(index: u32) -> Self {
        Self::Index(index)
    }

    #[must_use]
    pub const fn range(start: u32, end: u32) -> Self {
        Self::Range { start, end }
    }

    #[must_use]
    pub const fn contains(self, index: u32) -> bool {
        match self {
            Self::Index(candidate) => candidate == index,
            Self::Range { start, end } => start <= index && index <= end,
        }
    }
}

impl FromStr for Selection {
    type Err = Error;

    fn from_str(raw: &str) -> Result<Self> {
        let text = raw.trim();
        let lower = text.to_ascii_lowercase();
        match lower.as_str() {
            "current" => Ok(Self::Current),
            "latest" | "last" | "new" => Ok(Self::Latest),
            "all" => Ok(Self::All),
            _ => {
                if let Some(id) = lower.strip_prefix("episode:") {
                    return parse_u64(id, "episode").map(Self::Episode);
                }
                if let Some(page) = lower.strip_prefix("page:") {
                    return parse_index_selection(page).map(selection_from_index_selection);
                }
                if looks_like_index_selection(&lower) {
                    return parse_index_selection(&lower).map(selection_from_index_selection);
                }
                Err(Error::InvalidInput(format!("invalid selection `{raw}`")))
            }
        }
    }
}

fn selection_from_index_selection(selection: IndexSelection) -> Selection {
    match selection.selectors.as_slice() {
        [IndexSelector::Index(index)] => Selection::Page(*index),
        _ => Selection::Indices(selection),
    }
}

fn parse_index_selection(text: &str) -> Result<IndexSelection> {
    let mut selectors = Vec::new();
    for token in text.split(',') {
        let token = token.trim();
        if token.is_empty() {
            return Err(Error::InvalidInput(format!(
                "invalid index selection `{text}`"
            )));
        }
        selectors.push(parse_index_selector(token)?);
    }
    IndexSelection::new(selectors)
}

fn parse_index_selector(text: &str) -> Result<IndexSelector> {
    if let Some((start, end)) = text.split_once('-') {
        let start = parse_u32(start.trim(), "index")?;
        let end = parse_u32(end.trim(), "index")?;
        return Ok(IndexSelector::Range { start, end });
    }
    parse_u32(text, "index").map(IndexSelector::Index)
}

fn looks_like_index_selection(text: &str) -> bool {
    text.bytes()
        .all(|byte| byte.is_ascii_digit() || matches!(byte, b',' | b'-' | b' ' | b'\t'))
}

fn validate_index_selector(selector: IndexSelector) -> Result<()> {
    match selector {
        IndexSelector::Index(0) | IndexSelector::Range { start: 0, .. } => Err(
            Error::InvalidInput("index selections are 1-based".to_owned()),
        ),
        IndexSelector::Range { start, end } if end == 0 || start > end => Err(Error::InvalidInput(
            format!("invalid index range `{start}-{end}`"),
        )),
        IndexSelector::Index(_) | IndexSelector::Range { .. } => Ok(()),
    }
}

fn parse_u64(text: &str, label: &str) -> Result<u64> {
    text.parse::<u64>()
        .map_err(|_| Error::InvalidInput(format!("invalid {label} selection `{text}`")))
}

fn parse_u32(text: &str, label: &str) -> Result<u32> {
    text.parse::<u32>()
        .map_err(|_| Error::InvalidInput(format!("invalid {label} selection `{text}`")))
}

#[cfg(test)]
mod tests {
    use super::{IndexSelection, IndexSelector, Selection};
    use crate::Error;

    #[test]
    fn parses_single_bare_index_as_page_selection() -> anyhow::Result<()> {
        assert_eq!("2".parse::<Selection>()?, Selection::Page(2));
        Ok(())
    }

    #[test]
    fn parses_page_range_and_list_selection() -> anyhow::Result<()> {
        assert_eq!(
            "page:1,3-5,8".parse::<Selection>()?,
            Selection::Indices(IndexSelection::new([
                IndexSelector::Index(1),
                IndexSelector::Range { start: 3, end: 5 },
                IndexSelector::Index(8),
            ])?)
        );
        Ok(())
    }

    #[test]
    fn rejects_zero_and_reversed_ranges() {
        for input in ["0", "page:0", "page:3-1", "1,,2"] {
            let error = input.parse::<Selection>().err();
            assert!(
                matches!(error, Some(Error::InvalidInput(_))),
                "expected invalid input for {input:?}, got {error:?}"
            );
        }
    }
}