Some checks failed
CI/CD Pipeline / Test (push) Failing after 22m19s
CI/CD Pipeline / Security Scan (push) Failing after 5m57s
CI/CD Pipeline / Build (amd64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (amd64, linux) (push) Has been skipped
CI/CD Pipeline / Build (amd64, windows) (push) Has been skipped
CI/CD Pipeline / Build (arm64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (arm64, linux) (push) Has been skipped
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Create Release (push) Has been skipped
654 lines
17 KiB
Go
654 lines
17 KiB
Go
package generator
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
yaml "gopkg.in/yaml.v3"
|
|
|
|
"github.com/subconverter-go/internal/logging"
|
|
"github.com/subconverter-go/internal/parser"
|
|
)
|
|
|
|
// ClashGenerator Clash格式生成器
|
|
// 实现Clash代理配置的生成功能
|
|
type ClashGenerator struct {
|
|
logger *logging.Logger
|
|
}
|
|
|
|
// NewClashGenerator 创建新的Clash生成器
|
|
// 返回初始化好的ClashGenerator实例
|
|
func NewClashGenerator(logger *logging.Logger) *ClashGenerator {
|
|
return &ClashGenerator{
|
|
logger: logger,
|
|
}
|
|
}
|
|
|
|
// Generate 生成Clash配置
|
|
func (g *ClashGenerator) Generate(configs []*parser.ProxyConfig, options *GenerationOptions) (string, error) {
|
|
g.logger.Debugf("Generating Clash configuration")
|
|
|
|
// 创建Clash配置结构
|
|
clashConfig := g.createClashConfig(configs, options)
|
|
|
|
// 转换为YAML
|
|
yamlData, err := yaml.Marshal(clashConfig)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to marshal Clash configuration: %v", err)
|
|
}
|
|
|
|
return string(yamlData), nil
|
|
}
|
|
|
|
// ValidateOptions 验证Clash生成选项
|
|
func (g *ClashGenerator) ValidateOptions(options *GenerationOptions) error {
|
|
g.logger.Debugf("Validating Clash generation options")
|
|
|
|
// 验证基本信息
|
|
if options.Name == "" {
|
|
return fmt.Errorf("configuration name is required")
|
|
}
|
|
|
|
// 验证模式
|
|
validModes := []string{"global", "rule", "direct"}
|
|
if options.Mode != "" {
|
|
valid := false
|
|
for _, mode := range validModes {
|
|
if options.Mode == mode {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
return fmt.Errorf("invalid mode: %s, must be one of: %s", options.Mode, strings.Join(validModes, ", "))
|
|
}
|
|
}
|
|
|
|
// 验证端口
|
|
if options.MixedPort < 0 || options.MixedPort > 65535 {
|
|
return fmt.Errorf("invalid mixed port: %d", options.MixedPort)
|
|
}
|
|
|
|
// 验证日志级别
|
|
validLogLevels := []string{"info", "warning", "error", "debug", "silent"}
|
|
if options.LogLevel != "" {
|
|
valid := false
|
|
for _, level := range validLogLevels {
|
|
if options.LogLevel == level {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
if !valid {
|
|
return fmt.Errorf("invalid log level: %s, must be one of: %s", options.LogLevel, strings.Join(validLogLevels, ", "))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetSupportedFormats 获取支持的格式
|
|
func (g *ClashGenerator) GetSupportedFormats() []string {
|
|
return []string{"clash"}
|
|
}
|
|
|
|
// ClashConfig Clash配置结构体
|
|
type ClashConfig struct {
|
|
Port int `yaml:"port" json:"port"`
|
|
SocksPort int `yaml:"socks-port" json:"socks-port"`
|
|
AllowLan bool `yaml:"allow-lan" json:"allow-lan"`
|
|
Mode string `yaml:"mode" json:"mode"`
|
|
LogLevel string `yaml:"log-level" json:"log-level"`
|
|
ExternalController string `yaml:"external-controller" json:"external-controller"`
|
|
Secret string `yaml:"secret,omitempty" json:"secret,omitempty"`
|
|
Proxies []map[string]interface{} `yaml:"proxies" json:"proxies"`
|
|
ProxyGroup []map[string]interface{} `yaml:"proxy-groups" json:"proxy-groups"`
|
|
Rules []string `yaml:"rules" json:"rules"`
|
|
ProxyProviders map[string]map[string]interface{} `yaml:"proxy-providers,omitempty" json:"proxy-providers,omitempty"`
|
|
}
|
|
|
|
// createClashConfig 创建Clash配置结构
|
|
func (g *ClashGenerator) createClashConfig(configs []*parser.ProxyConfig, options *GenerationOptions) *ClashConfig {
|
|
config := &ClashConfig{
|
|
Port: 7890,
|
|
SocksPort: 7891,
|
|
AllowLan: options.AllowLan,
|
|
Mode: options.Mode,
|
|
LogLevel: options.LogLevel,
|
|
ExternalController: options.ExternalController,
|
|
Secret: options.Secret,
|
|
Proxies: make([]map[string]interface{}, 0),
|
|
ProxyGroup: make([]map[string]interface{}, 0),
|
|
Rules: make([]string, 0),
|
|
}
|
|
|
|
// 设置默认值
|
|
if config.Port == 0 {
|
|
config.Port = 7890
|
|
}
|
|
if config.SocksPort == 0 {
|
|
config.SocksPort = 7891
|
|
}
|
|
if config.Mode == "" {
|
|
config.Mode = "rule"
|
|
}
|
|
if config.LogLevel == "" {
|
|
config.LogLevel = "info"
|
|
}
|
|
|
|
// 生成代理配置
|
|
for _, proxyConfig := range configs {
|
|
proxy := g.convertProxyConfig(proxyConfig)
|
|
config.Proxies = append(config.Proxies, proxy)
|
|
}
|
|
|
|
// 生成代理组
|
|
config.ProxyGroup = g.createProxyGroups(configs, options)
|
|
if len(options.GroupDefinitions) > 0 {
|
|
for _, def := range options.GroupDefinitions {
|
|
config.ProxyGroup = append(config.ProxyGroup, g.buildClashGroup(def, configs))
|
|
}
|
|
} else if len(options.CustomGroups) > 0 {
|
|
for _, entry := range options.CustomGroups {
|
|
group, err := g.parseCustomGroup(entry, configs)
|
|
if err != nil {
|
|
g.logger.WithError(err).Warnf("Failed to parse custom group: %s", entry)
|
|
continue
|
|
}
|
|
config.ProxyGroup = append(config.ProxyGroup, group)
|
|
}
|
|
}
|
|
|
|
if len(options.Providers) > 0 {
|
|
providers := make(map[string]map[string]interface{}, len(options.Providers))
|
|
for _, def := range options.Providers {
|
|
providers[def.Name] = g.buildClashProvider(def)
|
|
}
|
|
config.ProxyProviders = providers
|
|
} else if len(options.CustomProviders) > 0 {
|
|
providers := make(map[string]map[string]interface{})
|
|
for _, entry := range options.CustomProviders {
|
|
parsed, err := ParseProviderDefinition(entry)
|
|
if err != nil {
|
|
g.logger.WithError(err).Warnf("Failed to parse custom provider: %s", entry)
|
|
continue
|
|
}
|
|
providers[parsed.Name] = g.buildClashProvider(parsed)
|
|
}
|
|
if len(providers) > 0 {
|
|
config.ProxyProviders = providers
|
|
}
|
|
}
|
|
|
|
// 生成规则
|
|
rules := g.createRules(options)
|
|
config.Rules = g.applyCustomRulesets(rules, options)
|
|
|
|
return config
|
|
}
|
|
|
|
// convertProxyConfig 转换代理配置为Clash格式
|
|
func (g *ClashGenerator) convertProxyConfig(proxyConfig *parser.ProxyConfig) map[string]interface{} {
|
|
proxy := map[string]interface{}{
|
|
"name": proxyConfig.Name,
|
|
"type": proxyConfig.Type,
|
|
"server": proxyConfig.Server,
|
|
"port": proxyConfig.Port,
|
|
"udp": proxyConfig.UDP,
|
|
}
|
|
|
|
// 根据协议类型添加特定配置
|
|
switch proxyConfig.Protocol {
|
|
case "ss":
|
|
if method, exists := proxyConfig.Settings["method"]; exists {
|
|
proxy["cipher"] = method
|
|
}
|
|
if password, exists := proxyConfig.Settings["password"]; exists {
|
|
proxy["password"] = password
|
|
}
|
|
if plugin, exists := proxyConfig.Settings["plugin"]; exists {
|
|
proxy["plugin"] = plugin
|
|
}
|
|
|
|
case "ssr":
|
|
if method, exists := proxyConfig.Settings["method"]; exists {
|
|
proxy["cipher"] = method
|
|
}
|
|
if password, exists := proxyConfig.Settings["password"]; exists {
|
|
proxy["password"] = password
|
|
}
|
|
if protocol, exists := proxyConfig.Settings["protocol"]; exists {
|
|
proxy["protocol"] = protocol
|
|
}
|
|
if obfs, exists := proxyConfig.Settings["obfs"]; exists {
|
|
proxy["obfs"] = obfs
|
|
}
|
|
|
|
case "vmess":
|
|
if uuid, exists := proxyConfig.Settings["uuid"]; exists {
|
|
proxy["uuid"] = uuid
|
|
}
|
|
if alterId, exists := proxyConfig.Settings["alterId"]; exists {
|
|
proxy["alterId"] = alterId
|
|
}
|
|
if network, exists := proxyConfig.Settings["network"]; exists {
|
|
proxy["network"] = network
|
|
}
|
|
if tls, exists := proxyConfig.Settings["tls"]; exists {
|
|
proxy["tls"] = tls
|
|
}
|
|
if host, exists := proxyConfig.Settings["host"]; exists {
|
|
proxy["servername"] = host
|
|
}
|
|
if path, exists := proxyConfig.Settings["path"]; exists {
|
|
proxy["ws-path"] = path
|
|
}
|
|
|
|
case "trojan":
|
|
if password, exists := proxyConfig.Settings["password"]; exists {
|
|
proxy["password"] = password
|
|
}
|
|
if sni, exists := proxyConfig.Settings["sni"]; exists {
|
|
proxy["sni"] = sni
|
|
}
|
|
if network, exists := proxyConfig.Settings["network"]; exists {
|
|
proxy["network"] = network
|
|
}
|
|
|
|
case "http", "https":
|
|
if username, exists := proxyConfig.Settings["username"]; exists {
|
|
proxy["username"] = username
|
|
}
|
|
if password, exists := proxyConfig.Settings["password"]; exists {
|
|
proxy["password"] = password
|
|
}
|
|
if tls, exists := proxyConfig.Settings["tls"]; exists {
|
|
proxy["tls"] = tls
|
|
}
|
|
|
|
case "socks5":
|
|
if username, exists := proxyConfig.Settings["username"]; exists {
|
|
proxy["username"] = username
|
|
}
|
|
if password, exists := proxyConfig.Settings["password"]; exists {
|
|
proxy["password"] = password
|
|
}
|
|
}
|
|
|
|
return proxy
|
|
}
|
|
|
|
// createProxyGroups 创建代理组
|
|
func (g *ClashGenerator) createProxyGroups(configs []*parser.ProxyConfig, options *GenerationOptions) []map[string]interface{} {
|
|
groups := make([]map[string]interface{}, 0)
|
|
|
|
// 创建代理名称列表
|
|
proxyNames := make([]string, 0)
|
|
for _, config := range configs {
|
|
proxyNames = append(proxyNames, config.Name)
|
|
}
|
|
|
|
// 创建选择代理组
|
|
selectGroup := map[string]interface{}{
|
|
"name": "PROXY",
|
|
"type": "select",
|
|
"proxies": proxyNames,
|
|
}
|
|
groups = append(groups, selectGroup)
|
|
|
|
// 创建自动选择代理组(如果启用了代理测试)
|
|
if options.ProxyTest {
|
|
urlTestGroup := map[string]interface{}{
|
|
"name": "URL-TEST",
|
|
"type": "url-test",
|
|
"proxies": proxyNames,
|
|
"url": options.ProxyURL,
|
|
"interval": 300,
|
|
"tolerance": 50,
|
|
}
|
|
if urlTestGroup["url"] == "" {
|
|
urlTestGroup["url"] = "http://www.gstatic.com/generate_204"
|
|
}
|
|
groups = append(groups, urlTestGroup)
|
|
}
|
|
|
|
// 创建故障转移代理组
|
|
fallbackGroup := map[string]interface{}{
|
|
"name": "FALLBACK",
|
|
"type": "fallback",
|
|
"proxies": proxyNames,
|
|
"url": "http://www.gstatic.com/generate_204",
|
|
"interval": 300,
|
|
}
|
|
groups = append(groups, fallbackGroup)
|
|
|
|
// 创建直连和拒绝代理组
|
|
groups = append(groups, map[string]interface{}{
|
|
"name": "DIRECT",
|
|
"type": "select",
|
|
"proxies": []string{"DIRECT"},
|
|
})
|
|
|
|
groups = append(groups, map[string]interface{}{
|
|
"name": "REJECT",
|
|
"type": "select",
|
|
"proxies": []string{"REJECT"},
|
|
})
|
|
|
|
return groups
|
|
}
|
|
|
|
func (g *ClashGenerator) buildClashGroup(def *GroupDefinition, configs []*parser.ProxyConfig) map[string]interface{} {
|
|
group := map[string]interface{}{
|
|
"name": def.Name,
|
|
"type": def.Type,
|
|
}
|
|
|
|
proxies := make([]string, 0, len(def.Proxies))
|
|
proxies = append(proxies, def.Proxies...)
|
|
if len(proxies) == 0 && len(def.UseProviders) == 0 {
|
|
for _, cfg := range configs {
|
|
proxies = append(proxies, cfg.Name)
|
|
}
|
|
}
|
|
if len(proxies) > 0 {
|
|
group["proxies"] = proxies
|
|
}
|
|
if len(def.UseProviders) > 0 {
|
|
group["use"] = def.UseProviders
|
|
}
|
|
if def.URL != "" {
|
|
group["url"] = def.URL
|
|
}
|
|
if def.Interval != nil {
|
|
group["interval"] = *def.Interval
|
|
}
|
|
if def.Timeout != nil {
|
|
group["timeout"] = *def.Timeout
|
|
}
|
|
if def.Tolerance != nil {
|
|
group["tolerance"] = *def.Tolerance
|
|
}
|
|
for k, v := range def.Extras {
|
|
group[k] = v
|
|
}
|
|
|
|
return group
|
|
}
|
|
|
|
func (g *ClashGenerator) buildClashProvider(def *ProviderDefinition) map[string]interface{} {
|
|
provider := map[string]interface{}{
|
|
"type": def.Type,
|
|
"url": def.URL,
|
|
"path": def.Path,
|
|
}
|
|
if def.Interval != nil {
|
|
provider["interval"] = *def.Interval
|
|
}
|
|
if len(def.Fields) > 0 {
|
|
keys := make([]string, 0, len(def.Fields))
|
|
for k := range def.Fields {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
provider[k] = def.Fields[k]
|
|
}
|
|
}
|
|
if len(def.Flags) > 0 {
|
|
keys := make([]string, 0, len(def.Flags))
|
|
for k := range def.Flags {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, k := range keys {
|
|
provider[k] = def.Flags[k]
|
|
}
|
|
}
|
|
if def.Health != nil {
|
|
health := make(map[string]interface{})
|
|
if def.Health.Enable != nil {
|
|
health["enable"] = *def.Health.Enable
|
|
}
|
|
if def.Health.URL != "" {
|
|
health["url"] = def.Health.URL
|
|
}
|
|
if def.Health.Interval != nil {
|
|
health["interval"] = *def.Health.Interval
|
|
}
|
|
if def.Health.Lazy != nil {
|
|
health["lazy"] = *def.Health.Lazy
|
|
}
|
|
if def.Health.Tolerance != nil {
|
|
health["tolerance"] = *def.Health.Tolerance
|
|
}
|
|
if def.Health.Timeout != nil {
|
|
health["timeout"] = *def.Health.Timeout
|
|
}
|
|
if def.Health.Method != "" {
|
|
health["method"] = def.Health.Method
|
|
}
|
|
if def.Health.Headers != "" {
|
|
health["headers"] = def.Health.Headers
|
|
}
|
|
if len(health) > 0 {
|
|
provider["health-check"] = health
|
|
}
|
|
}
|
|
|
|
return provider
|
|
}
|
|
|
|
// createRules 创建规则
|
|
func (g *ClashGenerator) createRules(options *GenerationOptions) []string {
|
|
rules := make([]string, 0)
|
|
|
|
// 添加默认规则
|
|
if len(options.Rules) > 0 {
|
|
rules = append(rules, options.Rules...)
|
|
} else {
|
|
// 添加默认规则集
|
|
defaultRules := []string{
|
|
"DOMAIN-SUFFIX,bilibili.com,DIRECT",
|
|
"DOMAIN-SUFFIX,baidu.com,DIRECT",
|
|
"DOMAIN-SUFFIX,cn,DIRECT",
|
|
"DOMAIN-KEYWORD,google,PROXY",
|
|
"DOMAIN-KEYWORD,github,PROXY",
|
|
"DOMAIN-KEYWORD,twitter,PROXY",
|
|
"DOMAIN-KEYWORD,facebook,PROXY",
|
|
"DOMAIN-KEYWORD,youtube,PROXY",
|
|
"DOMAIN-KEYWORD,instagram,PROXY",
|
|
"DOMAIN-KEYWORD,telegram,PROXY",
|
|
"GEOIP,CN,DIRECT",
|
|
"IP-CIDR,127.0.0.0/8,DIRECT",
|
|
"IP-CIDR,192.168.0.0/16,DIRECT",
|
|
"IP-CIDR,10.0.0.0/8,DIRECT",
|
|
"IP-CIDR,172.16.0.0/12,DIRECT",
|
|
"MATCH,PROXY",
|
|
}
|
|
rules = append(rules, defaultRules...)
|
|
}
|
|
|
|
// 启用IPv6支持
|
|
if options.IPv6 {
|
|
ipv6Rules := []string{
|
|
"IP-CIDR6,::1/128,DIRECT",
|
|
"IP-CIDR6,fc00::/7,DIRECT",
|
|
}
|
|
rules = append(ipv6Rules, rules...)
|
|
}
|
|
|
|
// 启用局域网支持
|
|
if options.EnableLan {
|
|
lanRules := []string{
|
|
"SRC-IP-CIDR,192.168.0.0/16,DIRECT",
|
|
"SRC-IP-CIDR,10.0.0.0/8,DIRECT",
|
|
"SRC-IP-CIDR,172.16.0.0/12,DIRECT",
|
|
}
|
|
rules = append(lanRules, rules...)
|
|
}
|
|
|
|
return rules
|
|
}
|
|
|
|
func (g *ClashGenerator) parseCustomGroup(definition string, configs []*parser.ProxyConfig) (map[string]interface{}, error) {
|
|
values, err := url.ParseQuery(definition)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid group definition: %v", err)
|
|
}
|
|
|
|
name := values.Get("name")
|
|
if name == "" {
|
|
return nil, fmt.Errorf("custom group missing name")
|
|
}
|
|
|
|
groupType := values.Get("type")
|
|
if groupType == "" {
|
|
groupType = "select"
|
|
}
|
|
|
|
useProviders := make([]string, 0)
|
|
if useStr := values.Get("use"); useStr != "" {
|
|
for _, entry := range strings.Split(useStr, ",") {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry != "" {
|
|
useProviders = append(useProviders, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
proxies := make([]string, 0)
|
|
if proxyStr := values.Get("proxies"); proxyStr != "" {
|
|
for _, entry := range strings.Split(proxyStr, ",") {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry != "" {
|
|
proxies = append(proxies, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(proxies) == 0 && len(useProviders) == 0 {
|
|
for _, cfg := range configs {
|
|
proxies = append(proxies, cfg.Name)
|
|
}
|
|
}
|
|
|
|
group := map[string]interface{}{
|
|
"name": name,
|
|
"type": groupType,
|
|
"proxies": proxies,
|
|
}
|
|
if len(useProviders) > 0 {
|
|
group["use"] = useProviders
|
|
}
|
|
|
|
if urlValue := values.Get("url"); urlValue != "" {
|
|
group["url"] = urlValue
|
|
}
|
|
if interval := values.Get("interval"); interval != "" {
|
|
if n, err := strconv.Atoi(interval); err == nil && n > 0 {
|
|
group["interval"] = n
|
|
}
|
|
}
|
|
if tolerance := values.Get("tolerance"); tolerance != "" {
|
|
if n, err := strconv.Atoi(tolerance); err == nil && n >= 0 {
|
|
group["tolerance"] = n
|
|
}
|
|
}
|
|
if timeout := values.Get("timeout"); timeout != "" {
|
|
if n, err := strconv.Atoi(timeout); err == nil && n >= 0 {
|
|
group["timeout"] = n
|
|
}
|
|
}
|
|
|
|
return group, nil
|
|
}
|
|
|
|
func (g *ClashGenerator) applyCustomRulesets(existing []string, options *GenerationOptions) []string {
|
|
rules := make([]string, 0, len(existing))
|
|
for _, rule := range existing {
|
|
rule = strings.TrimSpace(rule)
|
|
if rule == "" {
|
|
continue
|
|
}
|
|
rules = append(rules, rule)
|
|
}
|
|
|
|
for _, entry := range options.CustomRulesets {
|
|
entry = strings.TrimSpace(entry)
|
|
if entry == "" {
|
|
continue
|
|
}
|
|
lower := strings.ToLower(entry)
|
|
switch {
|
|
case strings.HasPrefix(lower, "ruleset,"):
|
|
payload := strings.TrimSpace(entry[len("ruleset,"):])
|
|
if payload == "" {
|
|
continue
|
|
}
|
|
lines, err := g.loadRulesetLines(options.BasePath, payload)
|
|
if err != nil {
|
|
g.logger.WithError(err).Warnf("Failed to load ruleset %s", payload)
|
|
continue
|
|
}
|
|
rules = append(rules, lines...)
|
|
case strings.HasPrefix(lower, "rule,"):
|
|
statement := strings.TrimSpace(entry[len("rule,"):])
|
|
if statement != "" {
|
|
rules = append(rules, statement)
|
|
}
|
|
default:
|
|
rules = append(rules, entry)
|
|
}
|
|
}
|
|
|
|
return normalizeRules(rules)
|
|
}
|
|
|
|
func (g *ClashGenerator) loadRulesetLines(basePath, relative string) ([]string, error) {
|
|
path := relative
|
|
if basePath != "" && !filepath.IsAbs(relative) {
|
|
path = filepath.Join(basePath, relative)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
content := strings.ReplaceAll(string(data), "\r\n", "\n")
|
|
lines := make([]string, 0)
|
|
for _, line := range strings.Split(content, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") {
|
|
continue
|
|
}
|
|
lines = append(lines, trimmed)
|
|
}
|
|
return lines, nil
|
|
}
|
|
|
|
func normalizeRules(rules []string) []string {
|
|
seen := make(map[string]struct{})
|
|
result := make([]string, 0, len(rules))
|
|
for _, rule := range rules {
|
|
rule = strings.TrimSpace(rule)
|
|
if rule == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[rule]; ok {
|
|
continue
|
|
}
|
|
seen[rule] = struct{}{}
|
|
result = append(result, rule)
|
|
}
|
|
return result
|
|
}
|