1use fancy_regex::Regex;
4use std::collections::HashMap;
5
6use crate::error::TemplateError;
7use crate::types::{LineOp, RecordOp, Transition};
8
9#[derive(Debug, Clone)]
11pub struct Rule {
12 pub match_pattern: String,
14
15 pub regex_pattern: String,
17
18 pub(crate) regex: Regex,
20
21 pub line_op: LineOp,
23
24 pub record_op: RecordOp,
26
27 pub transition: Transition,
29
30 pub line_num: usize,
32}
33
34const RESERVED_LINE_OPS: &[&str] = &["Continue", "Next", "Error"];
36const RESERVED_RECORD_OPS: &[&str] = &["Clear", "Clearall", "Record", "NoRecord"];
37
38impl Rule {
39 pub fn parse(
41 line: &str,
42 line_num: usize,
43 value_templates: &HashMap<String, String>,
44 ) -> Result<Self, TemplateError> {
45 let trimmed = line.trim();
46
47 if !trimmed.starts_with('^') {
48 return Err(TemplateError::InvalidRule {
49 line: line_num,
50 message: "rule must start with '^'".into(),
51 });
52 }
53
54 let (match_pattern, action_str) = if let Some(idx) = trimmed.find(" -> ") {
56 (&trimmed[..idx], Some(&trimmed[idx + 4..]))
57 } else {
58 (trimmed, None)
59 };
60
61 let regex_pattern = Self::substitute_variables(match_pattern, value_templates, line_num)?;
63
64 let regex = Regex::new(®ex_pattern).map_err(|e| TemplateError::InvalidRegex {
66 pattern: regex_pattern.clone(),
67 message: e.to_string(),
68 })?;
69
70 let (line_op, record_op, transition) = if let Some(action) = action_str {
72 Self::parse_action(action.trim(), line_num)?
73 } else {
74 (LineOp::default(), RecordOp::default(), Transition::default())
75 };
76
77 if line_op == LineOp::Continue && !matches!(transition, Transition::Stay) {
79 return Err(TemplateError::ContinueWithTransition(line_num));
80 }
81
82 Ok(Self {
83 match_pattern: match_pattern.to_string(),
84 regex_pattern,
85 regex,
86 line_op,
87 record_op,
88 transition,
89 line_num,
90 })
91 }
92
93 fn substitute_variables(
94 pattern: &str,
95 templates: &HashMap<String, String>,
96 line_num: usize,
97 ) -> Result<String, TemplateError> {
98 let mut result = pattern.to_string();
99
100 while let Some(start) = result.find("${") {
103 let end = match result[start..].find('}') {
104 Some(i) => start + i,
105 None => {
106 return Err(TemplateError::InvalidRule {
107 line: line_num,
108 message: "unclosed variable substitution".into(),
109 })
110 }
111 };
112
113 let var_name = &result[start + 2..end];
114
115 let template = templates.get(var_name).ok_or_else(|| {
116 TemplateError::InvalidSubstitution {
117 line: line_num,
118 message: format!("unknown variable '{}'", var_name),
119 }
120 })?;
121
122 result = format!("{}{}{}", &result[..start], template, &result[end + 1..]);
123 }
124
125 Ok(result)
126 }
127
128 fn parse_action(
129 action: &str,
130 line_num: usize,
131 ) -> Result<(LineOp, RecordOp, Transition), TemplateError> {
132 if action.is_empty() {
133 return Ok((LineOp::default(), RecordOp::default(), Transition::default()));
134 }
135
136 let parts: Vec<&str> = action.split_whitespace().collect();
137
138 let mut line_op = LineOp::default();
139 let mut record_op = RecordOp::default();
140 let mut transition = Transition::default();
141 let mut idx = 0;
142
143 if idx < parts.len() {
144 let first = parts[idx];
145
146 if let Some((line_part, record_part)) = first.split_once('.') {
148 line_op = Self::parse_line_op(line_part, line_num)?;
149 record_op = Self::parse_record_op(record_part, line_num)?;
150 idx += 1;
151 } else if let Some(op) = Self::try_parse_line_op(first) {
152 line_op = op;
153 idx += 1;
154
155 if line_op == LineOp::Error && idx < parts.len() {
158 let rest = parts[idx..].join(" ");
159 if let Some(stripped) = rest.strip_prefix('"') {
160 if let Some(end_quote) = stripped.find('"') {
162 let message = stripped[..end_quote].to_string();
163 transition = Transition::State(message);
164 } else {
165 transition = Transition::State(rest.trim_matches('"').to_string());
167 }
168 return Ok((line_op, record_op, transition));
169 }
170 }
171 } else if let Some(op) = Self::try_parse_record_op(first) {
172 record_op = op;
173 idx += 1;
174 } else {
175 transition = Self::parse_transition(first);
177 idx += 1;
178 }
179 }
180
181 if idx < parts.len() && matches!(transition, Transition::Stay) {
183 transition = Self::parse_transition(parts[idx]);
184 }
185
186 Ok((line_op, record_op, transition))
187 }
188
189 fn try_parse_line_op(s: &str) -> Option<LineOp> {
190 match s {
191 "Next" => Some(LineOp::Next),
192 "Continue" => Some(LineOp::Continue),
193 "Error" => Some(LineOp::Error),
194 _ => None,
195 }
196 }
197
198 fn parse_line_op(s: &str, line_num: usize) -> Result<LineOp, TemplateError> {
199 Self::try_parse_line_op(s).ok_or_else(|| TemplateError::InvalidRule {
200 line: line_num,
201 message: format!("invalid line operator '{}'", s),
202 })
203 }
204
205 fn try_parse_record_op(s: &str) -> Option<RecordOp> {
206 match s {
207 "NoRecord" => Some(RecordOp::NoRecord),
208 "Record" => Some(RecordOp::Record),
209 "Clear" => Some(RecordOp::Clear),
210 "Clearall" => Some(RecordOp::ClearAll),
211 _ => None,
212 }
213 }
214
215 fn parse_record_op(s: &str, line_num: usize) -> Result<RecordOp, TemplateError> {
216 Self::try_parse_record_op(s).ok_or_else(|| TemplateError::InvalidRule {
217 line: line_num,
218 message: format!("invalid record operator '{}'", s),
219 })
220 }
221
222 fn parse_transition(s: &str) -> Transition {
223 match s {
224 "End" => Transition::End,
225 "EOF" => Transition::Eof,
226 _ => {
227 if s.starts_with('"') && s.ends_with('"') {
229 Transition::State(s[1..s.len() - 1].to_string())
230 } else {
231 Transition::State(s.to_string())
232 }
233 }
234 }
235 }
236}
237
238#[derive(Debug, Clone)]
240pub struct State {
241 pub name: String,
243
244 pub rules: Vec<Rule>,
246}
247
248impl State {
249 pub fn new(name: String) -> Self {
251 Self {
252 name,
253 rules: Vec::new(),
254 }
255 }
256
257 pub fn is_valid_name(name: &str) -> bool {
259 if name.is_empty() || name.len() > 48 {
260 return false;
261 }
262
263 if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
265 return false;
266 }
267
268 if RESERVED_LINE_OPS.contains(&name) || RESERVED_RECORD_OPS.contains(&name) {
270 return false;
271 }
272
273 true
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn empty_templates() -> HashMap<String, String> {
282 HashMap::new()
283 }
284
285 fn sample_templates() -> HashMap<String, String> {
286 let mut m = HashMap::new();
287 m.insert("Interface".into(), "(?P<Interface>\\S+)".into());
288 m.insert("Status".into(), "(?P<Status>up|down)".into());
289 m
290 }
291
292 #[test]
293 fn test_parse_simple_rule() {
294 let r = Rule::parse("^Interface: (\\S+)", 1, &empty_templates()).unwrap();
295 assert_eq!(r.match_pattern, "^Interface: (\\S+)");
296 assert_eq!(r.line_op, LineOp::Next);
297 assert_eq!(r.record_op, RecordOp::NoRecord);
298 assert!(matches!(r.transition, Transition::Stay));
299 }
300
301 #[test]
302 fn test_parse_rule_with_record() {
303 let r = Rule::parse("^End -> Record", 1, &empty_templates()).unwrap();
304 assert_eq!(r.line_op, LineOp::Next);
305 assert_eq!(r.record_op, RecordOp::Record);
306 }
307
308 #[test]
309 fn test_parse_rule_with_compound_action() {
310 let r = Rule::parse("^Line -> Next.Record", 1, &empty_templates()).unwrap();
311 assert_eq!(r.line_op, LineOp::Next);
312 assert_eq!(r.record_op, RecordOp::Record);
313 }
314
315 #[test]
316 fn test_parse_rule_with_state_transition() {
317 let r = Rule::parse("^Start -> Continue.Record NextState", 1, &empty_templates());
318 assert!(matches!(r, Err(TemplateError::ContinueWithTransition(_))));
320 }
321
322 #[test]
323 fn test_parse_rule_with_variable_substitution() {
324 let templates = sample_templates();
325 let r = Rule::parse("^Interface: ${Interface} is ${Status}", 1, &templates).unwrap();
326 assert!(r.regex_pattern.contains("(?P<Interface>"));
327 assert!(r.regex_pattern.contains("(?P<Status>"));
328 }
329
330 #[test]
331 fn test_state_valid_names() {
332 assert!(State::is_valid_name("Start"));
333 assert!(State::is_valid_name("State1"));
334 assert!(State::is_valid_name("my_state"));
335 assert!(!State::is_valid_name("Continue")); assert!(!State::is_valid_name("Record")); assert!(!State::is_valid_name("")); }
339}