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        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            // Check for compound operator: LineOp.RecordOp
147            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                // Special case: Error with quoted message
156                // e.g., Error "LINE NOT FOUND" - the message is not a state
157                if line_op == LineOp::Error && idx < parts.len() {
158                    let rest = parts[idx..].join(" ");
159                    if let Some(stripped) = rest.strip_prefix('"') {
160                        // Extract quoted error message
161                        if let Some(end_quote) = stripped.find('"') {
162                            let message = stripped[..end_quote].to_string();
163                            transition = Transition::State(message);
164                        } else {
165                            // Unterminated quote, take the rest as message
166                            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                // Must be a state name
176                transition = Self::parse_transition(first);
177                idx += 1;
178            }
179        }
180
181        // Parse second part if present (should be state name)
182        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                // Handle quoted error messages
228                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/// A state containing rules.
239#[derive(Debug, Clone)]
240pub struct State {
241    /// Name of this state.
242    pub name: String,
243
244    /// Rules in this state, checked in order.
245    pub rules: Vec<Rule>,
246}
247
248impl State {
249    /// Create a new empty state.
250    pub fn new(name: String) -> Self {
251        Self {
252            name,
253            rules: Vec::new(),
254        }
255    }
256
257    /// Check if a name is valid for a state.
258    pub fn is_valid_name(name: &str) -> bool {
259        if name.is_empty() || name.len() > 48 {
260            return false;
261        }
262
263        // Must be alphanumeric/underscore
264        if !name.chars().all(|c| c.is_alphanumeric() || c == '_') {
265            return false;
266        }
267
268        // Cannot be a reserved word
269        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        // Continue with state transition should fail
319        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")); // reserved
336        assert!(!State::is_valid_name("Record")); // reserved
337        assert!(!State::is_valid_name("")); // empty
338    }
339}