subconverter/generator/ruleconvert/
convert_ruleset.rs

1//! Rule conversion implementation between different proxy configuration formats
2//!
3//! Converts proxy rule formats between Clash, Surge, and Quantumult X
4
5use crate::models::RulesetType;
6use crate::utils::network::is_ipv4;
7use crate::utils::string::ends_with;
8use regex::Regex;
9
10/// Converts a ruleset from one format to another
11///
12/// # Arguments
13///
14/// * `content` - The ruleset content to convert
15/// * `ruleset_type` - The target ruleset type
16///
17/// # Returns
18///
19/// The converted ruleset content
20pub fn convert_ruleset(content: &str, ruleset_type: RulesetType) -> String {
21    // If target type is Surge, return content as is
22    if ruleset_type == RulesetType::Surge {
23        return content.to_string();
24    }
25
26    let mut output = String::new();
27    let payload_regex = Regex::new(r"^payload:\r?\n").unwrap();
28
29    if payload_regex.is_match(content) {
30        // Convert Clash ruleset to Surge format
31
32        // First, replace the payload header and format the rules
33        let content_without_header = content.replace("payload:", "").trim().to_string();
34
35        // Process each line to extract rule content
36        let mut rule_items_formatted = String::new();
37        for line in content_without_header.lines() {
38            let line = line.trim();
39            if line.starts_with('-') {
40                // Extract the actual rule content, removing the dash and quotes
41                let mut rule_content = line[1..].trim().to_string();
42                if rule_content.starts_with('\'') && rule_content.ends_with('\'') {
43                    rule_content = rule_content[1..rule_content.len() - 1].to_string();
44                } else if rule_content.starts_with('"') && rule_content.ends_with('"') {
45                    rule_content = rule_content[1..rule_content.len() - 1].to_string();
46                }
47                rule_items_formatted.push_str(&rule_content);
48                rule_items_formatted.push('\n');
49            }
50        }
51
52        // If target is Clash Classical, return the formatted rules
53        if ruleset_type == RulesetType::ClashClassical {
54            return rule_items_formatted;
55        }
56
57        // Process each line and convert to appropriate format
58        for line in rule_items_formatted.lines() {
59            let mut line = line.trim().to_string();
60
61            // Remove trailing \r if present
62            if line.ends_with('\r') {
63                line.pop();
64            }
65
66            // Remove comments
67            if let Some(comment_pos) = line.find("//") {
68                line = line[..comment_pos].trim().to_string();
69            }
70
71            // Skip empty lines and comments
72            if line.is_empty()
73                || line.starts_with(';')
74                || line.starts_with('#')
75                || (line.len() >= 2 && line.starts_with("//"))
76            {
77                continue;
78            }
79
80            // Process actual rules
81            if let Some(pos) = line.find('/') {
82                // IP-CIDR or IP-CIDR6 classification
83                if is_ipv4(&line[..pos]) {
84                    output.push_str("IP-CIDR,");
85                } else {
86                    output.push_str("IP-CIDR6,");
87                }
88                output.push_str(&line);
89            } else if line.starts_with('.') || (line.len() >= 2 && line.starts_with("+.")) {
90                // Domain suffix or keyword
91                let mut keyword_flag = false;
92                let mut rule_content = line.clone();
93
94                // Check for keyword pattern (ends with .*)
95                while ends_with(&rule_content, ".*") {
96                    keyword_flag = true;
97                    rule_content = rule_content[..rule_content.len() - 2].to_string();
98                }
99
100                output.push_str("DOMAIN-");
101                if keyword_flag {
102                    output.push_str("KEYWORD,");
103                } else {
104                    output.push_str("SUFFIX,");
105                }
106
107                // Remove leading dot or "+."
108                if rule_content.starts_with("+.") {
109                    rule_content = rule_content[2..].to_string();
110                } else if rule_content.starts_with('.') {
111                    rule_content = rule_content[1..].to_string();
112                }
113
114                output.push_str(&rule_content);
115            } else {
116                // Plain domain
117                output.push_str("DOMAIN,");
118                output.push_str(&line);
119            }
120
121            output.push('\n');
122        }
123    } else {
124        // Convert Quantumult X ruleset to Surge format
125
126        // Replace HOST with DOMAIN and IP6-CIDR with IP-CIDR6
127        let host_regex = Regex::new(r"(?i)^host").unwrap();
128        let ip6_cidr_regex = Regex::new(r"(?i)^ip6-cidr").unwrap();
129
130        let mut processed = host_regex.replace_all(content, "DOMAIN").to_string();
131        processed = ip6_cidr_regex
132            .replace_all(&processed, "IP-CIDR6")
133            .to_string();
134
135        // Remove group info and standardize format
136        // This regex matches the rule type and pattern, removes any group, and preserves no-resolve if present
137        let rule_format_regex = Regex::new(
138            r"^((?i:DOMAIN(?:-(?:SUFFIX|KEYWORD))?|IP-CIDR6?|USER-AGENT),)\s*?(\S*?)(?:,(?!no-resolve).*?)(,no-resolve)?$"
139        ).unwrap();
140
141        output = rule_format_regex
142            .replace_all(&processed, "$1$2$3")
143            .to_string();
144
145        // Convert rule types to uppercase
146        let domain_regex = Regex::new(r"^(domain)").unwrap();
147        let domain_suffix_regex = Regex::new(r"^(domain-suffix)").unwrap();
148        let domain_keyword_regex = Regex::new(r"^(domain-keyword)").unwrap();
149        let ip_cidr_regex = Regex::new(r"^(ip-cidr)").unwrap();
150        let ip_cidr6_regex = Regex::new(r"^(ip-cidr6)").unwrap();
151        let user_agent_regex = Regex::new(r"^(user-agent)").unwrap();
152
153        output = domain_regex.replace_all(&output, "DOMAIN").to_string();
154        output = domain_suffix_regex
155            .replace_all(&output, "DOMAIN-SUFFIX")
156            .to_string();
157        output = domain_keyword_regex
158            .replace_all(&output, "DOMAIN-KEYWORD")
159            .to_string();
160        output = ip_cidr_regex.replace_all(&output, "IP-CIDR").to_string();
161        output = ip_cidr6_regex.replace_all(&output, "IP-CIDR6").to_string();
162        output = user_agent_regex
163            .replace_all(&output, "USER-AGENT")
164            .to_string();
165    }
166
167    output
168}