Skip to main content

textfsm_core/clitable/
index.rs

1//! Index file parsing and template matching.
2//!
3//! Parses CSV-format index files that map attributes (Command, Platform, Hostname)
4//! to template files.
5
6use std::collections::HashMap;
7use std::io::BufRead;
8use std::path::Path;
9
10use fancy_regex::Regex;
11
12use super::completion::expand_completion;
13use super::CliTableError;
14
15/// Parsed index file.
16#[derive(Debug, Clone)]
17pub struct Index {
18    /// Column names from header row.
19    columns: Vec<String>,
20
21    /// Index entries (data rows).
22    entries: Vec<IndexEntry>,
23}
24
25impl Index {
26    /// Parse an index from a reader.
27    pub fn parse<R: BufRead>(reader: R) -> Result<Self, CliTableError> {
28        let mut columns: Vec<String> = Vec::new();
29        let mut entries: Vec<IndexEntry> = Vec::new();
30        let mut line_num = 0;
31
32        for line in reader.lines() {
33            line_num += 1;
34            let line = line.map_err(|e| CliTableError::IndexParse {
35                line: line_num,
36                message: e.to_string(),
37            })?;
38
39            let trimmed = line.trim();
40
41            // Skip empty lines and comments
42            if trimmed.is_empty() || trimmed.starts_with('#') {
43                continue;
44            }
45
46            // Parse CSV line
47            let fields: Vec<String> = parse_csv_line(trimmed);
48
49            if columns.is_empty() {
50                // First non-comment line is the header
51                columns = fields.into_iter().map(|s| s.trim().to_string()).collect();
52
53                // Validate required Template column
54                if !columns.iter().any(|c| c == "Template") {
55                    return Err(CliTableError::MissingColumn("Template".into()));
56                }
57            } else {
58                // Data row
59                let entry = IndexEntry::parse(&columns, fields, line_num)?;
60                entries.push(entry);
61            }
62        }
63
64        if columns.is_empty() {
65            return Err(CliTableError::IndexParse {
66                line: 0,
67                message: "empty index file (no header row)".into(),
68            });
69        }
70
71        Ok(Self { columns, entries })
72    }
73
74    /// Parse an index from a string.
75    pub fn parse_str(s: &str) -> Result<Self, CliTableError> {
76        Self::parse(s.as_bytes())
77    }
78
79    /// Parse an index from a file.
80    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, CliTableError> {
81        let file = std::fs::File::open(path)?;
82        let reader = std::io::BufReader::new(file);
83        Self::parse(reader)
84    }
85
86    /// Find the first matching entry for the given attributes.
87    ///
88    /// Returns the first entry where all provided attributes match.
89    /// Attributes not present in the index are silently ignored.
90    pub fn find_match(&self, attributes: &HashMap<String, String>) -> Option<&IndexEntry> {
91        self.entries.iter().find(|entry| entry.matches(&self.columns, attributes))
92    }
93
94    /// Get all matching entries.
95    pub fn find_all_matches(&self, attributes: &HashMap<String, String>) -> Vec<&IndexEntry> {
96        self.entries
97            .iter()
98            .filter(|entry| entry.matches(&self.columns, attributes))
99            .collect()
100    }
101
102    /// Get column names.
103    pub fn columns(&self) -> &[String] {
104        &self.columns
105    }
106
107    /// Get all entries.
108    pub fn entries(&self) -> &[IndexEntry] {
109        &self.entries
110    }
111
112    /// Get all unique template names referenced in this index.
113    pub fn all_templates(&self) -> Vec<&str> {
114        let mut templates: Vec<&str> = Vec::new();
115        for entry in &self.entries {
116            for template in &entry.templates {
117                if !templates.contains(&template.as_str()) {
118                    templates.push(template);
119                }
120            }
121        }
122        templates
123    }
124}
125
126/// A single row in the index file.
127#[derive(Debug, Clone)]
128pub struct IndexEntry {
129    /// Template file names (colon-separated in the CSV).
130    templates: Vec<String>,
131
132    /// Compiled regex patterns for each attribute column.
133    /// None for the Template column (index 0).
134    patterns: Vec<Option<Regex>>,
135
136    /// Original string values from CSV (for debugging).
137    raw_values: Vec<String>,
138
139    /// Line number in the index file (for error reporting).
140    line_num: usize,
141}
142
143impl IndexEntry {
144    /// Parse an index entry from CSV fields.
145    fn parse(columns: &[String], fields: Vec<String>, line_num: usize) -> Result<Self, CliTableError> {
146        let mut templates: Vec<String> = Vec::new();
147        let mut patterns: Vec<Option<Regex>> = Vec::new();
148        let mut raw_values: Vec<String> = Vec::new();
149
150        for (i, column) in columns.iter().enumerate() {
151            let value = fields.get(i).map(|s| s.trim().to_string()).unwrap_or_default();
152            raw_values.push(value.clone());
153
154            if column == "Template" {
155                // Split on ':' for multi-template entries
156                templates = value
157                    .split(':')
158                    .map(|s| s.trim().to_string())
159                    .filter(|s| !s.is_empty())
160                    .collect();
161                patterns.push(None);
162            } else {
163                // Expand completion syntax and compile regex
164                if value.is_empty() {
165                    patterns.push(None);
166                } else {
167                    let expanded = if column == "Command" {
168                        expand_completion(&value)?
169                    } else {
170                        value.clone()
171                    };
172
173                    // Anchor pattern at start to match Python's re.match() behavior
174                    let anchored = if expanded.starts_with('^') {
175                        expanded
176                    } else {
177                        format!("^{}", expanded)
178                    };
179
180                    let regex = Regex::new(&anchored).map_err(|e| CliTableError::InvalidRegex {
181                        line: line_num,
182                        message: format!("{}: {}", column, e),
183                    })?;
184                    patterns.push(Some(regex));
185                }
186            }
187        }
188
189        if templates.is_empty() {
190            return Err(CliTableError::IndexParse {
191                line: line_num,
192                message: "empty Template field".into(),
193            });
194        }
195
196        Ok(Self {
197            templates,
198            patterns,
199            raw_values,
200            line_num,
201        })
202    }
203
204    /// Check if this entry matches the given attributes.
205    ///
206    /// All provided attributes must match (AND logic).
207    /// Attributes not in the index are silently ignored.
208    pub fn matches(&self, columns: &[String], attributes: &HashMap<String, String>) -> bool {
209        for (i, column) in columns.iter().enumerate() {
210            // Skip Template column
211            if column == "Template" {
212                continue;
213            }
214
215            // Get the pattern for this column
216            if let Some(Some(pattern)) = self.patterns.get(i) {
217                // Get the attribute value (empty string if not provided)
218                let attr_value = attributes.get(column).map(|s| s.as_str()).unwrap_or("");
219
220                // Check if pattern matches
221                match pattern.is_match(attr_value) {
222                    Ok(true) => continue,
223                    Ok(false) => return false,
224                    Err(_) => return false,
225                }
226            }
227            // If no pattern for this column, it matches anything
228        }
229
230        true
231    }
232
233    /// Get template names.
234    pub fn templates(&self) -> &[String] {
235        &self.templates
236    }
237
238    /// Get the raw CSV values.
239    pub fn raw_values(&self) -> &[String] {
240        &self.raw_values
241    }
242
243    /// Get the line number in the index file.
244    pub fn line_num(&self) -> usize {
245        self.line_num
246    }
247}
248
249/// Simple CSV line parser (handles quoted fields with commas).
250fn parse_csv_line(line: &str) -> Vec<String> {
251    let mut fields = Vec::new();
252    let mut current = String::new();
253    let mut in_quotes = false;
254    let mut chars = line.chars().peekable();
255
256    while let Some(c) = chars.next() {
257        match c {
258            '"' if !in_quotes => {
259                in_quotes = true;
260            }
261            '"' if in_quotes => {
262                // Check for escaped quote
263                if chars.peek() == Some(&'"') {
264                    chars.next();
265                    current.push('"');
266                } else {
267                    in_quotes = false;
268                }
269            }
270            ',' if !in_quotes => {
271                fields.push(current.trim().to_string());
272                current = String::new();
273            }
274            _ => {
275                current.push(c);
276            }
277        }
278    }
279
280    fields.push(current.trim().to_string());
281    fields
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_parse_simple_index() {
290        let csv = r#"Template, Hostname, Command
291template_a.textfsm, .*, show version
292template_b.textfsm, .*, show interfaces
293"#;
294        let index = Index::parse_str(csv).unwrap();
295        assert_eq!(index.columns().len(), 3);
296        assert_eq!(index.entries().len(), 2);
297        assert_eq!(index.entries()[0].templates(), &["template_a.textfsm"]);
298        assert_eq!(index.entries()[1].templates(), &["template_b.textfsm"]);
299    }
300
301    #[test]
302    fn test_parse_with_comments() {
303        let csv = r#"# This is a comment
304Template, Command
305
306# Another comment
307template.textfsm, show version
308"#;
309        let index = Index::parse_str(csv).unwrap();
310        assert_eq!(index.entries().len(), 1);
311    }
312
313    #[test]
314    fn test_parse_multi_template() {
315        let csv = r#"Template, Command
316template_a.textfsm:template_b.textfsm, show version
317"#;
318        let index = Index::parse_str(csv).unwrap();
319        assert_eq!(
320            index.entries()[0].templates(),
321            &["template_a.textfsm", "template_b.textfsm"]
322        );
323    }
324
325    #[test]
326    fn test_find_match() {
327        let csv = r#"Template, Platform, Command
328cisco_show_version.textfsm, cisco_ios, show version
329arista_show_version.textfsm, arista_eos, show version
330cisco_show_interfaces.textfsm, cisco_ios, show interfaces
331"#;
332        let index = Index::parse_str(csv).unwrap();
333
334        let mut attrs = HashMap::new();
335        attrs.insert("Platform".into(), "cisco_ios".into());
336        attrs.insert("Command".into(), "show version".into());
337
338        let entry = index.find_match(&attrs).unwrap();
339        assert_eq!(entry.templates(), &["cisco_show_version.textfsm"]);
340    }
341
342    #[test]
343    fn test_find_match_with_regex() {
344        let csv = r#"Template, Platform, Command
345cisco_show_version.textfsm, cisco_.*, show version
346"#;
347        let index = Index::parse_str(csv).unwrap();
348
349        let mut attrs = HashMap::new();
350        attrs.insert("Platform".into(), "cisco_ios".into());
351        attrs.insert("Command".into(), "show version".into());
352
353        let entry = index.find_match(&attrs);
354        assert!(entry.is_some());
355
356        // Different platform should not match
357        attrs.insert("Platform".into(), "arista_eos".into());
358        let entry = index.find_match(&attrs);
359        assert!(entry.is_none());
360    }
361
362    #[test]
363    fn test_find_match_with_completion() {
364        let csv = r#"Template, Platform, Command
365cisco_show_version.textfsm, cisco_ios, sh[[ow]] ver[[sion]]
366"#;
367        let index = Index::parse_str(csv).unwrap();
368
369        // Full command
370        let mut attrs = HashMap::new();
371        attrs.insert("Platform".into(), "cisco_ios".into());
372        attrs.insert("Command".into(), "show version".into());
373        assert!(index.find_match(&attrs).is_some(), "show version should match");
374
375        // Abbreviated command - both prefixes must still be present
376        // sh[[ow]] means: sh, sho, or show
377        // ver[[sion]] means: ver, vers, versi, versio, or version
378        attrs.insert("Command".into(), "sh ver".into());
379        assert!(index.find_match(&attrs).is_some(), "sh ver should match");
380
381        // Partial completion
382        attrs.insert("Command".into(), "sho vers".into());
383        assert!(index.find_match(&attrs).is_some(), "sho vers should match");
384
385        // "sh v" should NOT match because "ver" is the minimum required for the second word
386        attrs.insert("Command".into(), "sh v".into());
387        assert!(index.find_match(&attrs).is_none(), "sh v should NOT match (ver is required)");
388    }
389
390    #[test]
391    fn test_missing_template_column() {
392        let csv = r#"Platform, Command
393cisco_ios, show version
394"#;
395        let result = Index::parse_str(csv);
396        assert!(matches!(result, Err(CliTableError::MissingColumn(_))));
397    }
398
399    #[test]
400    fn test_empty_template() {
401        let csv = r#"Template, Command
402, show version
403"#;
404        let result = Index::parse_str(csv);
405        assert!(matches!(result, Err(CliTableError::IndexParse { .. })));
406    }
407
408    #[test]
409    fn test_all_templates() {
410        let csv = r#"Template, Command
411template_a.textfsm, show version
412template_b.textfsm:template_c.textfsm, show interfaces
413template_a.textfsm, show ip route
414"#;
415        let index = Index::parse_str(csv).unwrap();
416        let templates = index.all_templates();
417        assert_eq!(templates.len(), 3);
418        assert!(templates.contains(&"template_a.textfsm"));
419        assert!(templates.contains(&"template_b.textfsm"));
420        assert!(templates.contains(&"template_c.textfsm"));
421    }
422
423    #[test]
424    fn test_csv_with_quotes() {
425        let csv = r#"Template, Command
426template.textfsm, "show interfaces, all"
427"#;
428        let index = Index::parse_str(csv).unwrap();
429        assert_eq!(index.entries()[0].raw_values()[1], "show interfaces, all");
430    }
431}