Skip to main content

textfsm_core/template/
rule.rs

1//! Rule and State definitions.
2
3use fancy_regex::Regex;
4use std::collections::HashMap;
5
6use crate::error::TemplateError;
7use crate::types::{LineOp, RecordOp, Transition};
8
9/// A rule within a state.
10#[derive(Debug, Clone)]
11pub struct Rule {
12    /// Original match pattern from template (before variable substitution).
13    pub match_pattern: String,
14
15    /// Regex pattern after ${var} substitution.
16    pub regex_pattern: String,
17
18    /// Compiled regex for matching.
19    pub(crate) regex: Regex,
20
21    /// Line operator.
22    pub line_op: LineOp,
23
24    /// Record operator.
25    pub record_op: RecordOp,
26
27    /// State transition.
28    pub transition: Transition,
29
30    /// Line number in template (for error reporting).
31    pub line_num: usize,
32}
33
34/// Reserved words that cannot be state names.
35const RESERVED_LINE_OPS: &[&str] = &["Continue", "Next", "Error"];
36const RESERVED_RECORD_OPS: &[&str] = &["Clear", "Clearall", "Record", "NoRecord"];
37
38impl Rule {
39    /// Parse a rule line: `  ^pattern -> LineOp.RecordOp NewState`
40    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        // Split on " -> " to separate pattern from action
55        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        // Substitute ${var} with named capture patterns
62        let regex_pattern = Self::substitute_variables(match_pattern, value_templates, line_num)?;
63
64        // Compile regex
65        let regex = Regex::new(&regex_pattern).map_err(|e| TemplateError::InvalidRegex {
66            pattern: regex_pattern.clone(),
67            message: e.to_string(),
68        })?;
69
70        // Parse action if present
71        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        // Validate: Continue cannot have state transition
78        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        // Find all ${var} patterns using a simple approach
101        // We'll iterate and replace each one
102        loop {
103            let start = match result.find("${") {
104                Some(i) => i,
105                None => break,
106            };
107
108            let end = match result[start..].find('}') {
109                Some(i) => start + i,
110                None => {
111                    return Err(TemplateError::InvalidRule {
112                        line: line_num,
113                        message: "unclosed variable substitution".into(),
114                    })
115                }
116            };
117
118            let var_name = &result[start + 2..end];
119
120            let template = templates.get(var_name).ok_or_else(|| {
121                TemplateError::InvalidSubstitution {
122                    line: line_num,
123                    message: format!("unknown variable '{}'", var_name),
124                }
125            })?;
126
127            result = format!("{}{}{}", &result[..start], template, &result[end + 1..]);
128        }
129
130        Ok(result)
131    }
132
133    fn parse_action(
134        action: &str,
135        line_num: usize,
136    ) -> Result<(LineOp, RecordOp, Transition), TemplateError> {
137        if action.is_empty() {
138            return Ok((LineOp::default(), RecordOp::default(), Transition::default()));
139        }
140
141        let parts: Vec<&str> = action.split_whitespace().collect();
142
143        let mut line_op = LineOp::default();
144        let mut record_op = RecordOp::default();
145        let mut transition = Transition::default();
146        let mut idx = 0;
147
148        if idx < parts.len() {
149            let first = parts[idx];
150
151            // Check for compound operator: LineOp.RecordOp
152            if let Some((line_part, record_part)) = first.split_once('.') {
153                line_op = Self::parse_line_op(line_part, line_num)?;
154                record_op = Self::parse_record_op(record_part, line_num)?;
155                idx += 1;
156            } else if let Some(op) = Self::try_parse_line_op(first) {
157                line_op = op;
158                idx += 1;
159
160                // Special case: Error with quoted message
161                // e.g., Error "LINE NOT FOUND" - the message is not a state
162                if line_op == LineOp::Error && idx < parts.len() {
163                    let rest = parts[idx..].join(" ");
164                    if rest.starts_with('"') {
165                        // Extract quoted error message
166                        if let Some(end_quote) = rest[1..].find('"') {
167                            let message = rest[1..end_quote + 1].to_string();
168                            transition = Transition::State(message);
169                        } else {
170                            // Unterminated quote, take the rest as message
171                            transition = Transition::State(rest.trim_matches('"').to_string());
172                        }
173                        return Ok((line_op, record_op, transition));
174                    }
175                }
176            } else if let Some(op) = Self::try_parse_record_op(first) {
177                record_op = op;
178                idx += 1;
179            } else {
180                // Must be a state name
181                transition = Self::parse_transition(first);
182                idx += 1;
183            }
184        }
185
186        // Parse second part if present (should be state name)
187        if idx < parts.len() && matches!(transition, Transition::Stay) {
188            transition = Self::parse_transition(parts[idx]);
189        }
190
191        Ok((line_op, record_op, transition))
192    }
193
194    fn try_parse_line_op(s: &str) -> Option<LineOp> {
195        match s {
196            "Next" => Some(LineOp::Next),
197            "Continue" => Some(LineOp::Continue),
198            "Error" => Some(LineOp::Error),
199            _ => None,
200        }
201    }
202
203    fn parse_line_op(s: &str, line_num: usize) -> Result<LineOp, TemplateError> {
204        Self::try_parse_line_op(s).ok_or_else(|| TemplateError::InvalidRule {
205            line: line_num,
206            message: format!("invalid line operator '{}'", s),
207        })
208    }
209
210    fn try_parse_record_op(s: &str) -> Option<RecordOp> {
211        match s {
212            "NoRecord" => Some(RecordOp::NoRecord),
213            "Record" => Some(RecordOp::Record),
214            "Clear" => Some(RecordOp::Clear),
215            "Clearall" => Some(RecordOp::ClearAll),
216            _ => None,
217        }
218    }
219
220    fn parse_record_op(s: &str, line_num: usize) -> Result<RecordOp, TemplateError> {
221        Self::try_parse_record_op(s).ok_or_else(|| TemplateError::InvalidRule {
222            line: line_num,
223            message: format!("invalid record operator '{}'", s),
224        })
225    }
226
227    fn parse_transition(s: &str) -> Transition {
228        match s {
229            "End" => Transition::End,
230            "EOF" => Transition::Eof,
231            _ => {
232                // Handle quoted error messages
233                if s.starts_with('"') && s.ends_with('"') {
234                    Transition::State(s[1..s.len() - 1].to_string())
235                } else {
236                    Transition::State(s.to_string())
237                }
238            }
239        }
240    }
241}
242
243/// A state containing rules.
244#[derive(Debug, Clone)]
245pub struct State {
246    /// Name of this state.
247    pub name: String,
248
249    /// Rules in this state, checked in order.
250    pub rules: Vec<Rule>,
251}
252
253impl State {
254    /// Create a new empty state.
255    pub fn new(name: String) -> Self {
256        Self {
257            name,
258            rules: Vec::new(),
259        }
260    }
261
262    /// Check if a name is valid for a state.
263    pub fn is_valid_name(name: &str) -> bool {
264        if name.is_empty() || name.len() > 48 {
265            return false;
266        }
267
268        // Must be alphanumeric/underscore
269        if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
270            return false;
271        }
272
273        // Cannot be a reserved word
274        if RESERVED_LINE_OPS.contains(&name) || RESERVED_RECORD_OPS.contains(&name) {
275            return false;
276        }
277
278        true
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    fn empty_templates() -> HashMap<String, String> {
287        HashMap::new()
288    }
289
290    fn sample_templates() -> HashMap<String, String> {
291        let mut m = HashMap::new();
292        m.insert("Interface".into(), "(?P<Interface>\\S+)".into());
293        m.insert("Status".into(), "(?P<Status>up|down)".into());
294        m
295    }
296
297    #[test]
298    fn test_parse_simple_rule() {
299        let r = Rule::parse("^Interface: (\\S+)", 1, &empty_templates()).unwrap();
300        assert_eq!(r.match_pattern, "^Interface: (\\S+)");
301        assert_eq!(r.line_op, LineOp::Next);
302        assert_eq!(r.record_op, RecordOp::NoRecord);
303        assert!(matches!(r.transition, Transition::Stay));
304    }
305
306    #[test]
307    fn test_parse_rule_with_record() {
308        let r = Rule::parse("^End -> Record", 1, &empty_templates()).unwrap();
309        assert_eq!(r.line_op, LineOp::Next);
310        assert_eq!(r.record_op, RecordOp::Record);
311    }
312
313    #[test]
314    fn test_parse_rule_with_compound_action() {
315        let r = Rule::parse("^Line -> Next.Record", 1, &empty_templates()).unwrap();
316        assert_eq!(r.line_op, LineOp::Next);
317        assert_eq!(r.record_op, RecordOp::Record);
318    }
319
320    #[test]
321    fn test_parse_rule_with_state_transition() {
322        let r = Rule::parse("^Start -> Continue.Record NextState", 1, &empty_templates());
323        // Continue with state transition should fail
324        assert!(matches!(r, Err(TemplateError::ContinueWithTransition(_))));
325    }
326
327    #[test]
328    fn test_parse_rule_with_variable_substitution() {
329        let templates = sample_templates();
330        let r = Rule::parse("^Interface: ${Interface} is ${Status}", 1, &templates).unwrap();
331        assert!(r.regex_pattern.contains("(?P<Interface>"));
332        assert!(r.regex_pattern.contains("(?P<Status>"));
333    }
334
335    #[test]
336    fn test_state_valid_names() {
337        assert!(State::is_valid_name("Start"));
338        assert!(State::is_valid_name("State1"));
339        assert!(State::is_valid_name("my_state"));
340        assert!(!State::is_valid_name("Continue")); // reserved
341        assert!(!State::is_valid_name("Record")); // reserved
342        assert!(!State::is_valid_name("")); // empty
343    }
344}