use std::collections::HashMap;
use std::path::Path;
use crate::types::RuleAnnotation;
pub fn extract_rule_annotations(
traces: &[clash_prism_core::trace::ExecutionTrace],
output_config: &serde_json::Value,
) -> Vec<RuleAnnotation> {
let mut annotations = Vec::new();
let rules_array = match output_config.get("rules").and_then(|v| v.as_array()) {
Some(arr) => arr,
None => return annotations,
};
let index = build_rule_index(rules_array);
for trace in traces {
let op_name = trace.op.display_name();
if op_name != "Prepend" && op_name != "Append" {
continue;
}
let source_file = trace.source.file.clone().unwrap_or_default();
let source_label = {
let mut label = source_file
.replace(".prism.yaml", "")
.replace(".prism.yml", "");
loop {
let trimmed = label.trim_start_matches(|c: char| c.is_numeric());
if trimmed.starts_with('-') && trimmed.len() > 1 {
label = trimmed[1..].to_string();
} else {
break;
}
}
label
};
let source_patch = trace.patch_id.as_str().to_string();
if let Some(bulk) = &trace.bulk_items {
for rule_text in bulk.iter() {
let pre_parsed = if rule_text.starts_with('{') {
serde_json::from_str::<serde_json::Value>(rule_text).ok()
} else {
None
};
if let Some(index) = find_by_index(&index, rule_text, &pre_parsed) {
annotations.push(RuleAnnotation {
rule_text: rule_text.clone(),
index_in_output: index,
source_file: source_file.clone(),
source_patch: source_patch.clone(),
source_label: source_label.clone(),
immutable: false,
});
}
}
} else {
for item in &trace.affected_items {
if let Some(rule_text) = &item.after {
let pre_parsed = if rule_text.starts_with('{') {
serde_json::from_str::<serde_json::Value>(rule_text).ok()
} else {
None
};
if let Some(index) = find_by_index(&index, rule_text, &pre_parsed) {
annotations.push(RuleAnnotation {
rule_text: rule_text.clone(),
index_in_output: index,
source_file: source_file.clone(),
source_patch: source_patch.clone(),
source_label: source_label.clone(),
immutable: false,
});
}
}
}
}
}
annotations.sort_by_key(|a| a.index_in_output);
annotations
}
struct RuleIndex<'a> {
string_positions: HashMap<&'a str, Vec<usize>>,
object_positions: HashMap<String, Vec<usize>>,
}
fn build_rule_index(rules: &[serde_json::Value]) -> RuleIndex<'_> {
let mut string_positions: HashMap<&str, Vec<usize>> = HashMap::new();
let mut object_positions: HashMap<String, Vec<usize>> = HashMap::new();
for (idx, rule) in rules.iter().enumerate() {
if let Some(s) = rule.as_str() {
string_positions.entry(s).or_default().push(idx);
} else {
let key = serde_json::to_string(rule).unwrap_or_default();
object_positions.entry(key).or_default().push(idx);
}
}
RuleIndex {
string_positions,
object_positions,
}
}
fn find_by_index<'a>(
index: &RuleIndex<'a>,
rule_text: &'a str,
pre_parsed: &Option<serde_json::Value>,
) -> Option<usize> {
if let Some(positions) = index.string_positions.get(rule_text) {
return positions.last().copied();
}
if let Some(parsed) = pre_parsed {
let key = serde_json::to_string(parsed).ok()?;
if let Some(positions) = index.object_positions.get(&key) {
return positions.last().copied();
}
}
None
}
#[cfg(test)]
fn find_rule_index(
rules: &[serde_json::Value],
rule_text: &str,
pre_parsed: &Option<serde_json::Value>,
) -> Option<usize> {
rules.iter().rposition(|r| {
if let Some(s) = r.as_str() {
return s == rule_text;
}
if let Some(parsed) = pre_parsed {
return *parsed == *r;
}
false
})
}
pub fn group_annotations(
annotations: &[RuleAnnotation],
workspace: &Path,
) -> Vec<crate::types::RuleGroup> {
use std::collections::BTreeMap;
let mut groups: BTreeMap<String, Vec<&RuleAnnotation>> = BTreeMap::new();
for ann in annotations {
groups.entry(ann.source_file.clone()).or_default().push(ann);
}
groups
.into_iter()
.filter(|(file_name, _)| {
if file_name.contains('\0') || file_name.contains("..") {
tracing::warn!(
target = "clash_prism_extension",
file_name = %file_name,
"group_annotations: file_name 包含危险字符,已跳过"
);
return false;
}
if file_name.starts_with('/') || file_name.starts_with('\\') {
tracing::warn!(
target = "clash_prism_extension",
file_name = %file_name,
"group_annotations: file_name 为绝对路径,已跳过"
);
return false;
}
true
})
.map(|(file_name, anns)| {
let label = anns
.first()
.map(|a| a.source_label.clone())
.unwrap_or_else(|| file_name.clone());
let disabled_marker = workspace.join(format!("{}.disabled", file_name));
let enabled = !disabled_marker.exists();
crate::types::RuleGroup {
group_id: file_name.clone(),
label,
patch_id: file_name,
enabled,
immutable: false,
rules: anns
.iter()
.map(|a| crate::types::RuleEntry {
raw: a.rule_text.clone(),
index: a.index_in_output,
})
.collect(),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use clash_prism_core::ir::PatchOp;
use clash_prism_core::source::{PatchSource, SourceKind};
use clash_prism_core::trace::{AffectedItem, ExecutionTrace, TraceSummary};
fn make_append_trace(file: Option<&str>, affected_items: Vec<AffectedItem>) -> ExecutionTrace {
ExecutionTrace::new(
clash_prism_core::ir::PatchId::new(),
PatchSource {
kind: SourceKind::YamlFile,
file: file.map(|s| s.to_string()),
line: None,
plugin_id: None,
},
PatchOp::Append,
10,
true,
TraceSummary::new(affected_items.len(), 0, 0, 0, 0, affected_items.len()),
affected_items,
)
}
#[test]
fn test_find_rule_index_string_match() {
let rules = vec![
serde_json::json!("DOMAIN-SUFFIX,google.com,PROXY"),
serde_json::json!("DOMAIN-KEYWORD,github,PROXY"),
serde_json::json!("MATCH,DIRECT"),
];
let idx = find_rule_index(&rules, "DOMAIN-KEYWORD,github,PROXY", &None);
assert_eq!(idx, Some(1), "应精确匹配到索引 1");
}
#[test]
fn test_find_rule_index_no_match() {
let rules = vec![
serde_json::json!("DOMAIN-SUFFIX,google.com,PROXY"),
serde_json::json!("MATCH,DIRECT"),
];
let idx = find_rule_index(&rules, "DOMAIN-KEYWORD,github,PROXY", &None);
assert_eq!(idx, None, "无匹配应返回 None");
}
#[test]
fn test_find_rule_index_rposition_strategy() {
let rules = vec![
serde_json::json!("DOMAIN-SUFFIX,ad.com,REJECT"),
serde_json::json!("MATCH,DIRECT"),
serde_json::json!("DOMAIN-SUFFIX,ad.com,REJECT"),
];
let idx = find_rule_index(&rules, "DOMAIN-SUFFIX,ad.com,REJECT", &None);
assert_eq!(
idx,
Some(2),
"重复规则时 rposition 应返回最后一个匹配(索引 2)"
);
}
#[test]
fn test_extract_rule_annotations_empty_traces() {
let output_config = serde_json::json!({
"rules": ["DOMAIN-SUFFIX,google.com,PROXY"]
});
let annotations = extract_rule_annotations(&[], &output_config);
assert!(annotations.is_empty(), "空 traces 应返回空注解");
}
#[test]
fn test_extract_rule_annotations_no_rules_field() {
let trace = make_append_trace(
Some("ad-filter.prism.yaml"),
vec![AffectedItem::added(0, "DOMAIN-SUFFIX,ad.com,REJECT")],
);
let output_config = serde_json::json!({
"dns": { "enable": true }
});
let annotations = extract_rule_annotations(&[trace], &output_config);
assert!(annotations.is_empty(), "输出配置无 rules 字段应返回空注解");
}
#[test]
fn test_source_label_strip_prefix() {
let label = {
let source_file = "01-ad-filter.prism.yaml";
let mut label = source_file
.replace(".prism.yaml", "")
.replace(".prism.yml", "");
loop {
let trimmed = label.trim_start_matches(|c: char| c.is_numeric());
if trimmed.starts_with('-') && trimmed.len() > 1 {
label = trimmed[1..].to_string();
} else {
break;
}
}
label
};
assert_eq!(
label, "ad-filter",
"\"01-ad-filter.prism.yaml\" 的 source_label 应为 \"ad-filter\""
);
}
#[test]
fn test_source_label_no_prefix() {
let label = {
let source_file = "rules.prism.yaml";
let mut label = source_file
.replace(".prism.yaml", "")
.replace(".prism.yml", "");
loop {
let trimmed = label.trim_start_matches(|c: char| c.is_numeric());
if trimmed.starts_with('-') && trimmed.len() > 1 {
label = trimmed[1..].to_string();
} else {
break;
}
}
label
};
assert_eq!(
label, "rules",
"\"rules.prism.yaml\" 的 source_label 应为 \"rules\""
);
}
}