Skip to main content

rinova_proxy_sdk/
lib.rs

1//! Convert proxy subscription links to Clash configuration files.
2//!
3//! # Quick start
4//!
5//! ```no_run
6//! use rinova_proxy_sdk::convert;
7//!
8//! #[tokio::main]
9//! async fn main() -> Result<(), rinova_proxy_sdk::ProxyError> {
10//!     let result = convert("https://your-subscription-url", None).await?;
11//!     println!("parsed {} nodes", result.nodes.len());
12//!     Ok(())
13//! }
14//! ```
15//!
16//! # Modules
17//!
18//! - [`parser`] — parse SS / VMess / Trojan / Hysteria2 URIs
19//! - [`fetch`] — fetch and decode subscription content
20//! - [`builder`] — assemble Clash YAML config
21//! - [`server`] — HTTP subscription service
22//! - [`i18n`] — localized messages (en/zh)
23
24pub mod builder;
25pub mod error;
26pub mod fetch;
27pub mod i18n;
28pub mod parser;
29pub mod server;
30pub mod types;
31pub mod utils;
32
33pub use builder::{build_config, to_yaml};
34pub use error::{ProxyError, Result};
35pub use fetch::{deduplicate_names, fetch_subscription};
36pub use i18n::{get_lang, t, t_simple, t_with_fallback, Lang};
37pub use parser::{parse_lines, parse_uri};
38pub use server::{start_server, ServerHandle, ServerOptions};
39pub use types::{
40    ClashConfig, ConvertOptions, ConvertResult, ProxyGroup, ProxyNode, ProxyType, RuleMode,
41};
42
43use builder::{build_config as build, to_yaml as serialize_yaml};
44use fetch::{deduplicate_names as dedup, fetch_subscription as fetch};
45use i18n::t as translate;
46use parser::parse_lines as parse;
47
48/// Fetch a subscription URL and convert it to a Clash configuration.
49pub async fn convert(url: &str, opts: Option<ConvertOptions>) -> Result<ConvertResult> {
50    let opts = opts.unwrap_or_default();
51    let lines = fetch(url).await?;
52    convert_from_parsed_lines(lines, opts, "err_no_nodes_subscription")
53}
54
55/// Convert pre-parsed URI lines without network requests.
56pub fn convert_from_lines(lines: &[impl AsRef<str>], opts: Option<ConvertOptions>) -> Result<ConvertResult> {
57    let lines: Vec<String> = lines.iter().map(|l| l.as_ref().to_string()).collect();
58    convert_from_parsed_lines(lines, opts.unwrap_or_default(), "err_no_nodes_input")
59}
60
61fn convert_from_parsed_lines(
62    lines: Vec<String>,
63    opts: ConvertOptions,
64    empty_err_key: &str,
65) -> Result<ConvertResult> {
66    let mut nodes = parse(&lines);
67
68    if nodes.is_empty() {
69        return Err(ProxyError::msg(translate(empty_err_key, &[])));
70    }
71
72    if opts.deduplicate {
73        dedup(&mut nodes);
74    }
75
76    let config = build(&nodes, opts.rules);
77    let yaml = serialize_yaml(&config)?;
78
79    Ok(ConvertResult {
80        config,
81        yaml,
82        nodes,
83    })
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn convert_from_lines_returns_yaml_config_nodes() {
92        let lines = vec![
93            "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@us1.example.com:8388#US-01".to_string(),
94            "trojan://pass@sg.example.com:443?security=tls&sni=sg.example.com#SG-01".to_string(),
95        ];
96        let result = convert_from_lines(&lines, None).unwrap();
97        assert_eq!(result.nodes.len(), 2);
98        assert!(result.yaml.contains("proxies:"));
99        assert!(result.yaml.contains("US-01"));
100    }
101
102    #[test]
103    fn convert_from_lines_deduplicates_by_default() {
104        let lines = vec![
105            "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@a.com:443#Node".to_string(),
106            "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@b.com:443#Node".to_string(),
107        ];
108        let result = convert_from_lines(&lines, None).unwrap();
109        assert_eq!(result.nodes[0].name, "Node");
110        assert_eq!(result.nodes[1].name, "Node-2");
111    }
112
113    #[test]
114    fn convert_from_lines_supports_external_rules() {
115        let lines = vec![
116            "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@us1.example.com:8388#US-01".to_string(),
117        ];
118        let result = convert_from_lines(
119            &lines,
120            Some(ConvertOptions {
121                rules: RuleMode::External,
122                deduplicate: true,
123            }),
124        )
125        .unwrap();
126        assert!(result.config.rules[0].starts_with("RULE-SET"));
127    }
128
129    #[test]
130    fn convert_from_lines_throws_on_empty() {
131        assert!(convert_from_lines(&[] as &[String], None).is_err());
132    }
133
134    #[test]
135    fn convert_from_lines_yaml_contains_groups_and_rules() {
136        let lines = vec![
137            "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@us1.example.com:8388#US-01".to_string(),
138            "trojan://pass@sg.example.com:443?security=tls&sni=sg.example.com#SG-01".to_string(),
139        ];
140        let result = convert_from_lines(&lines, None).unwrap();
141        assert!(result.yaml.contains("proxy-groups:"));
142        assert!(result.yaml.contains("rules:"));
143        assert!(result.yaml.contains("SG-01"));
144    }
145
146    #[test]
147    fn convert_from_lines_skips_dedup_when_false() {
148        let lines = vec![
149            "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@a.com:443#Node".to_string(),
150            "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@b.com:443#Node".to_string(),
151        ];
152        let result = convert_from_lines(
153            &lines,
154            Some(ConvertOptions {
155                rules: RuleMode::Builtin,
156                deduplicate: false,
157            }),
158        )
159        .unwrap();
160        assert_eq!(result.nodes[0].name, "Node");
161        assert_eq!(result.nodes[1].name, "Node");
162    }
163
164    #[test]
165    fn convert_from_lines_defaults_to_builtin_rules() {
166        let lines = vec![
167            "ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@us1.example.com:8388#US-01".to_string(),
168        ];
169        let result = convert_from_lines(&lines, None).unwrap();
170        assert!(result.config.rules[0].starts_with("DOMAIN-SUFFIX"));
171    }
172
173    #[test]
174    fn convert_from_lines_throws_when_all_unparseable() {
175        assert!(convert_from_lines(&["invalid://line".to_string()], None).is_err());
176    }
177
178    #[test]
179    fn parse_uri_and_build_config_to_yaml() {
180        let node = parse_uri("ss://YWVzLTI1Ni1nY206cGFzc3dvcmQ=@host:8388#Test").unwrap();
181        let config = build_config(&[node], RuleMode::Builtin);
182        let yaml = to_yaml(&config).unwrap();
183        assert!(yaml.contains("Test"));
184    }
185}