Skip to main content

trojan_rules/provider/
file.rs

1//! File-based rule-set provider.
2
3use std::path::Path;
4
5use crate::error::RulesError;
6use crate::parser;
7use crate::rule::ParsedRule;
8
9/// Provider that loads rule-sets from local files.
10#[derive(Debug)]
11pub struct FileProvider;
12
13impl FileProvider {
14    /// Load and parse a rule-set from a local file.
15    ///
16    /// - `format`: "surge" or "clash"
17    /// - `behavior`: Required for clash format ("domain", "ipcidr", "classical").
18    ///   For surge, use "classical" or "domain-set".
19    pub fn load(
20        path: &Path,
21        format: &str,
22        behavior: Option<&str>,
23    ) -> Result<Vec<ParsedRule>, RulesError> {
24        let content = std::fs::read_to_string(path)?;
25        Self::parse(&content, format, behavior)
26    }
27
28    /// Parse rule-set content from a string.
29    pub fn parse(
30        content: &str,
31        format: &str,
32        behavior: Option<&str>,
33    ) -> Result<Vec<ParsedRule>, RulesError> {
34        match format {
35            "surge" => match behavior {
36                Some("domain-set") | Some("domain") => parser::parse_surge_domain_set(content),
37                _ => parser::parse_surge_ruleset(content),
38            },
39            "clash" => {
40                let behavior = behavior.ok_or_else(|| {
41                    RulesError::Provider("behavior is required for clash format".into())
42                })?;
43                parser::parse_clash_provider(content, behavior)
44            }
45            _ => Err(RulesError::Provider(format!(
46                "unsupported format: {format}"
47            ))),
48        }
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    #[test]
57    fn parse_surge_classical() {
58        let content = "DOMAIN,example.com\nDOMAIN-SUFFIX,test.com";
59        let rules = FileProvider::parse(content, "surge", Some("classical")).unwrap();
60        assert_eq!(rules.len(), 2);
61    }
62
63    #[test]
64    fn parse_surge_domain_set() {
65        let content = "example.com\n.test.com";
66        let rules = FileProvider::parse(content, "surge", Some("domain-set")).unwrap();
67        assert_eq!(rules.len(), 2);
68    }
69
70    #[test]
71    fn parse_clash_domain() {
72        let content = "payload:\n  - 'example.com'\n  - '+.test.com'";
73        let rules = FileProvider::parse(content, "clash", Some("domain")).unwrap();
74        assert_eq!(rules.len(), 2);
75    }
76
77    #[test]
78    fn unsupported_format() {
79        let result = FileProvider::parse("", "unknown", None);
80        result.unwrap_err();
81    }
82
83    #[test]
84    fn clash_missing_behavior() {
85        let result = FileProvider::parse("payload: []", "clash", None);
86        result.unwrap_err();
87    }
88
89    #[test]
90    fn parse_surge_domain_behavior() {
91        // format="surge" with behavior="domain" should use domain-set parser
92        let content = "example.com\n.test.com";
93        let rules = FileProvider::parse(content, "surge", Some("domain")).unwrap();
94        assert_eq!(rules.len(), 2);
95        assert!(matches!(&rules[0], ParsedRule::Domain(d) if d == "example.com"));
96        assert!(matches!(&rules[1], ParsedRule::DomainSuffix(d) if d == "test.com"));
97    }
98}