textfsm_rs/
cli_table.rs

1use crate::{Result, TextFsmError};
2use fancy_regex::Regex;
3use log::{debug, trace};
4use std::collections::HashMap;
5use std::path::Path;
6
7/// Represents a CLI table index file parsed into memory.
8#[derive(Debug, Clone)]
9pub struct ParsedCliTable {
10    /// The filename of the index.
11    pub fname: String,
12    /// The rows of the table.
13    pub rows: Vec<CliTableRow>,
14}
15
16/// A high-level interface for command-to-template mapping using index files.
17#[derive(Debug, Clone)]
18pub struct CliTable {
19    /// List of parsed index tables.
20    pub tables: Vec<ParsedCliTable>,
21    /// Map of platform names to their associated regex rules for command matching.
22    pub platform_regex_rules: HashMap<String, Vec<CliTableRegexRule>>,
23}
24
25/// A rule for matching a command to a specific row in an index table.
26#[derive(Debug, Clone)]
27pub struct CliTableRegexRule {
28    /// Index into the `tables` vector.
29    pub table_index: usize,
30    /// Index into the `rows` vector of the selected table.
31    pub row_index: usize,
32    /// Pre-compiled regex for matching the CLI command.
33    pub command_regex: Regex,
34}
35
36/// A single entry in a CLI table index.
37#[derive(Debug, Clone, PartialEq)]
38pub struct CliTableRow {
39    /// List of TextFSM template filenames associated with this entry.
40    pub templates: Vec<String>,
41    /// Optional hostname filter (regex).
42    pub hostname: Option<String>,
43    /// Optional platform/vendor name.
44    pub platform: Option<String>,
45    /// The CLI command string (supports `[[abbrev]]` syntax).
46    pub command: String,
47}
48
49impl ParsedCliTable {
50    fn parse(fname: &Path) -> Result<Vec<CliTableRow>> {
51        use std::io::BufReader;
52        let file = std::fs::File::open(fname)?;
53        let reader = BufReader::new(file);
54        let mut rows: Vec<CliTableRow> = vec![];
55        let mut rdr = csv::ReaderBuilder::new()
56            .comment(Some(b'#'))
57            .has_headers(true)
58            .delimiter(b',')
59            .trim(csv::Trim::All)
60            .from_reader(reader);
61        trace!("Reader");
62
63        let headers: Vec<&str> = rdr.headers()?.into_iter().collect();
64        trace!("Headers: {:?}", &headers);
65
66        if !headers.contains(&"Template") {
67            return Err(TextFsmError::ParseError(
68                "No 'Template' column in index file".into(),
69            ));
70        }
71        if !headers.contains(&"Command") {
72            return Err(TextFsmError::ParseError(
73                "No 'Command' column in index file".into(),
74            ));
75        }
76
77        let template_position = headers.iter().position(|x| *x == "Template").unwrap();
78        let command_position = headers.iter().position(|x| *x == "Command").unwrap();
79        let maybe_platform_position = headers
80            .iter()
81            .position(|x| *x == "Platform")
82            .or_else(|| headers.iter().position(|x| *x == "Vendor"));
83        let maybe_hostname_position = headers.iter().position(|x| *x == "Hostname");
84
85        for result in rdr.records() {
86            let record = result?;
87            let platform: Option<String> =
88                maybe_platform_position.map(|ppos| record[ppos].to_string());
89            let hostname: Option<String> =
90                maybe_hostname_position.map(|hpos| record[hpos].to_string());
91            let templates: Vec<String> = record[template_position]
92                .split(':')
93                .map(|x| x.to_string())
94                .collect();
95            let command = record[command_position].to_string();
96
97            let row = CliTableRow {
98                templates,
99                hostname,
100                platform,
101                command,
102            };
103            rows.push(row);
104        }
105        Ok(rows)
106    }
107
108    /// Loads and parses a CLI table index from a file.
109    pub fn from_file<P: AsRef<Path>>(fname: P) -> Result<Self> {
110        let path = fname.as_ref();
111        debug!("Loading cli table from {}", path.display());
112        let rows = Self::parse(path)?;
113        Ok(ParsedCliTable {
114            fname: path.to_string_lossy().into_owned(),
115            rows,
116        })
117    }
118}
119
120impl CliTable {
121    /// Expands a string with optional tail characters into a regex group.
122    /// E.g., "show" -> "(s(h(o(w)?)?)?)?"
123    fn expand_string(input: &str) -> String {
124        if input.is_empty() {
125            return String::new();
126        }
127
128        let count = input.chars().count();
129        let mut result = String::with_capacity(count * 4);
130
131        for c in input.chars() {
132            result.push('(');
133            result.push(c);
134        }
135
136        for _ in 0..count {
137            result.push_str(")?");
138        }
139
140        result
141    }
142
143    /// Expands command abbreviations inside `[[ ]]` into nested optional regex groups.
144    fn expand_brackets(input: &str) -> String {
145        let mut result = String::with_capacity(input.len());
146        let mut current_pos = 0;
147
148        while let Some(start) = input[current_pos..].find("[[") {
149            // Add everything before the [[ to the result
150            result.push_str(&input[current_pos..current_pos + start]);
151
152            // Move position past the [[
153            let content_start = current_pos + start + 2;
154
155            // Look for matching ]]
156            if let Some(end) = input[content_start..].find("]]") {
157                let content = &input[content_start..content_start + end];
158                let expanded = Self::expand_string(content);
159                result.push_str(&expanded);
160                current_pos = content_start + end + 2;
161            } else {
162                // No matching ]], treat [[ as literal
163                result.push_str("[[");
164                current_pos = content_start;
165            }
166        }
167
168        // Add any remaining content
169        result.push_str(&input[current_pos..]);
170        result
171    }
172
173    fn get_directory(filename: &str) -> Option<String> {
174        let path = Path::new(filename);
175        path.parent().map(|p| p.to_string_lossy().into_owned())
176    }
177
178    /// Finds the appropriate template and row information for a given platform and command.
179    pub fn get_template_for_command(
180        &self,
181        platform: &str,
182        cmd: &str,
183    ) -> Option<(String, CliTableRow)> {
184        let plat_regex_list = self.platform_regex_rules.get(platform)?;
185        for rule in plat_regex_list {
186            if rule.command_regex.is_match(cmd).expect("Fancy regex ok?") {
187                let row = self.tables[rule.table_index].rows[rule.row_index].clone();
188                let fname = &self.tables[rule.table_index].fname;
189                if let Some(fdir) = Self::get_directory(fname) {
190                    return Some((fdir, row));
191                }
192            }
193        }
194        None
195    }
196
197    /// Loads a CLI table from an index file and compiles all command regexes.
198    pub fn from_file<P: AsRef<Path>>(fname: P) -> Result<Self> {
199        let parsed_cli_table = ParsedCliTable::from_file(fname)?;
200        let tables = vec![parsed_cli_table];
201        let mut platform_regex_rules: HashMap<String, Vec<CliTableRegexRule>> = Default::default();
202
203        for (table_index, table) in tables.iter().enumerate() {
204            for (row_index, row) in table.rows.iter().enumerate() {
205                let expanded_command = Self::expand_brackets(&row.command);
206                let anchored_command = format!("^{}$", expanded_command);
207                let command_regex = Regex::new(&anchored_command)
208                    .map_err(|e| TextFsmError::ParseError(e.to_string()))?;
209
210                let rule = CliTableRegexRule {
211                    table_index,
212                    row_index,
213                    command_regex,
214                };
215                let no_platform = "no-platform".to_string();
216                let platform_name: &str = row.platform.as_ref().unwrap_or(&no_platform);
217                platform_regex_rules
218                    .entry(platform_name.into())
219                    .or_default()
220                    .push(rule);
221            }
222        }
223        Ok(CliTable {
224            platform_regex_rules,
225            tables,
226        })
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_expand_string() {
236        assert_eq!(CliTable::expand_string(""), "");
237        assert_eq!(CliTable::expand_string("w"), "(w)?");
238        assert_eq!(CliTable::expand_string("sh"), "(s(h)?)?");
239        assert_eq!(CliTable::expand_string("sho"), "(s(h(o)?)?)?");
240        assert_eq!(CliTable::expand_string("show"), "(s(h(o(w)?)?)?)?");
241    }
242
243    #[test]
244    fn test_expand_brackets() {
245        assert_eq!(CliTable::expand_brackets("show"), "show");
246        assert_eq!(CliTable::expand_brackets("sh[[ow]]"), "sh(o(w)?)?");
247        assert_eq!(CliTable::expand_brackets("[[show]]"), "(s(h(o(w)?)?)?)?");
248        assert_eq!(
249            CliTable::expand_brackets("sh[[ow]] ip bgp"),
250            "sh(o(w)?)? ip bgp"
251        );
252        assert_eq!(
253            CliTable::expand_brackets("sh[[ow]] ip bgp su[[mmary]]"),
254            "sh(o(w)?)? ip bgp su(m(m(a(r(y)?)?)?)?)?"
255        );
256    }
257}