whycodes_config/
stream_rules.rs1use std::path::{Path, PathBuf};
8
9use crate::types::{Config, StreamRuleConfig, StreamRuleRepeat, StreamRuleScope};
10
11impl Config {
12 pub fn load_stream_rule_files(&mut self, project_dir: &Path) {
15 for rule in stream_rule_files(project_dir) {
16 let rules = &mut self.session.stream_rules;
17 match rules.iter_mut().find(|r| r.name.trim() == rule.name) {
18 Some(slot) => *slot = rule,
19 None => rules.push(rule),
20 }
21 }
22 }
23}
24
25pub fn stream_rule_files(project_dir: &Path) -> Vec<StreamRuleConfig> {
28 let [(dir, ext), ..] = whycodes_core::project_rule_dirs(project_dir);
29 let Ok(entries) = std::fs::read_dir(&dir) else {
30 return Vec::new();
31 };
32 let mut paths: Vec<PathBuf> = entries
33 .flatten()
34 .map(|e| e.path())
35 .filter(|p| {
36 p.is_file()
37 && p.extension()
38 .and_then(|e| e.to_str())
39 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
40 })
41 .collect();
42 paths.sort();
43 paths.iter().filter_map(|p| read_rule_file(p)).collect()
44}
45
46fn read_rule_file(path: &Path) -> Option<StreamRuleConfig> {
47 let text = match std::fs::read_to_string(path) {
48 Ok(t) => t,
49 Err(e) => {
50 tracing::warn!(path = %path.display(), error = %e, "stream rule file unreadable");
51 return None;
52 }
53 };
54 let stem = path
55 .file_stem()
56 .map(|s| s.to_string_lossy().into_owned())
57 .unwrap_or_default();
58 parse_rule_markdown(&stem, &text)
59}
60
61pub fn parse_rule_markdown(stem: &str, content: &str) -> Option<StreamRuleConfig> {
63 let rest = content
64 .trim_start_matches('\u{feff}')
65 .trim_start()
66 .strip_prefix("---")?;
67 let (front, body) = rest.split_once("\n---")?;
68 let body = body.split_once('\n').map_or("", |(_, b)| b);
69 let mut rule = StreamRuleConfig {
70 name: stem.to_string(),
71 hint: body.trim().to_string(),
72 ..Default::default()
73 };
74 let mut list_key = String::new();
76 for line in front.lines() {
77 let trimmed = line.trim();
78 if let Some(item) = trimmed.strip_prefix("- ") {
79 apply_key(&mut rule, &list_key, item, true);
80 continue;
81 }
82 let Some((k, v)) = trimmed.split_once(':') else {
83 continue;
84 };
85 list_key = k.trim().to_string();
86 apply_key(&mut rule, &list_key, v.trim(), false);
87 }
88 (!rule.pattern.is_empty()).then_some(rule)
89}
90
91fn apply_key(rule: &mut StreamRuleConfig, key: &str, value: &str, append: bool) {
92 if value.is_empty() {
93 return;
94 }
95 match key {
96 "pattern" => rule.pattern = unquote(value),
97 "name" => rule.name = unquote(value),
98 "scope" => {
99 if !append {
100 rule.scope.clear();
101 }
102 for item in list(value) {
103 match StreamRuleScope::parse(&item) {
104 Some(s) => rule.scope.push(s),
105 None => {
106 tracing::warn!(rule = %rule.name, scope = %item, "unknown stream rule scope")
107 }
108 }
109 }
110 }
111 "tools" => {
112 if !append {
113 rule.tools.clear();
114 }
115 rule.tools.extend(list(value));
116 }
117 "repeat" => match StreamRuleRepeat::parse(value) {
118 Some(r) => rule.repeat = r,
119 None => {
120 tracing::warn!(rule = %rule.name, repeat = %value, "unknown stream rule repeat")
121 }
122 },
123 _ => {}
124 }
125}
126
127fn list(value: &str) -> Vec<String> {
129 let inner = value.trim().trim_start_matches('[').trim_end_matches(']');
130 inner
131 .split(',')
132 .map(unquote)
133 .filter(|s| !s.is_empty())
134 .collect()
135}
136
137fn unquote(value: &str) -> String {
139 let v = value.trim();
140 if let Some(inner) = v.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
141 return inner.replace("''", "'");
142 }
143 let Some(inner) = v.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else {
144 return v.to_string();
145 };
146 let mut out = String::with_capacity(inner.len());
147 let mut chars = inner.chars();
148 while let Some(c) = chars.next() {
149 if c != '\\' {
150 out.push(c);
151 continue;
152 }
153 match chars.next() {
154 Some(n @ ('\\' | '"')) => out.push(n),
155 Some(n) => {
156 out.push('\\');
157 out.push(n);
158 }
159 None => out.push('\\'),
160 }
161 }
162 out
163}
164
165#[cfg(test)]
166#[path = "stream_rules_tests.rs"]
167mod tests;