rinova-proxy-sdk 1.0.0

Proxy subscription to Clash config converter — SDK
Documentation
use crate::error::{ProxyError, Result};
use crate::i18n::t;
use crate::utils::decode_base64_utf8;

const USER_AGENT: &str = "@rinova/proxy-sdk/1.0.0";

pub async fn fetch_subscription(url: &str) -> Result<Vec<String>> {
    let client = reqwest::Client::builder()
        .timeout(std::time::Duration::from_secs(15))
        .build()?;

    let resp = client
        .get(url)
        .header("User-Agent", USER_AGENT)
        .send()
        .await?
        .error_for_status()?
        .text()
        .await?;

    let mut raw = resp.trim().to_string();

    if raw.contains('%') {
        if let Ok(decoded) = urlencoding::decode(&raw) {
            raw = decoded.into_owned();
        }
    }

    let decoded = decode_base64_utf8(&raw).unwrap_or(raw);

    let lines: Vec<String> = decoded
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty() && !line.starts_with('#'))
        .map(str::to_string)
        .collect();

    if lines.is_empty() {
        return Err(ProxyError::msg(t("err_empty_subscription", &[])));
    }

    Ok(lines)
}

pub fn deduplicate_names(nodes: &mut [crate::types::ProxyNode]) {
    let mut seen = std::collections::HashMap::<String, u32>::new();
    for node in nodes.iter_mut() {
        let base = strip_numeric_suffix(&node.name).to_string();
        let count = seen.entry(base.clone()).or_insert(0);
        *count += 1;
        if *count > 1 {
            node.name = format!("{base}-{}", *count);
        }
    }
}

fn strip_numeric_suffix(name: &str) -> &str {
    if let Some(idx) = name.rfind('-') {
        let suffix = &name[idx + 1..];
        if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
            return &name[..idx];
        }
    }
    name
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{ProxyNode, ProxyType};

    #[test]
    fn deduplicate_preserves_unique_names() {
        let mut nodes = vec![
            ProxyNode::new("日本-01".into(), ProxyType::Ss, "a.com".into(), 443),
            ProxyNode::new("新加坡-01".into(), ProxyType::Ss, "b.com".into(), 443),
        ];
        deduplicate_names(&mut nodes);
        assert_eq!(nodes[0].name, "日本-01");
        assert_eq!(nodes[1].name, "新加坡-01");
    }

    #[test]
    fn pad_base64_roundtrip() {
        let raw = "ss://line1\nss://line2";
        use base64::Engine;
        let encoded = base64::engine::general_purpose::STANDARD.encode(raw);
        let decoded = decode_base64_utf8(&encoded).unwrap();
        assert_eq!(decoded, raw);
    }
}