use std::collections::HashMap;
use std::io::BufRead;
use std::path::Path;
use fancy_regex::Regex;
use super::completion::expand_completion;
use super::CliTableError;
#[derive(Debug, Clone)]
pub struct Index {
columns: Vec<String>,
entries: Vec<IndexEntry>,
}
impl Index {
pub fn parse<R: BufRead>(reader: R) -> Result<Self, CliTableError> {
let mut columns: Vec<String> = Vec::new();
let mut entries: Vec<IndexEntry> = Vec::new();
let mut line_num = 0;
for line in reader.lines() {
line_num += 1;
let line = line.map_err(|e| CliTableError::IndexParse {
line: line_num,
message: e.to_string(),
})?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let fields: Vec<String> = parse_csv_line(trimmed);
if columns.is_empty() {
columns = fields.into_iter().map(|s| s.trim().to_string()).collect();
if !columns.iter().any(|c| c == "Template") {
return Err(CliTableError::MissingColumn("Template".into()));
}
} else {
let entry = IndexEntry::parse(&columns, fields, line_num)?;
entries.push(entry);
}
}
if columns.is_empty() {
return Err(CliTableError::IndexParse {
line: 0,
message: "empty index file (no header row)".into(),
});
}
Ok(Self { columns, entries })
}
pub fn parse_str(s: &str) -> Result<Self, CliTableError> {
Self::parse(s.as_bytes())
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, CliTableError> {
let file = std::fs::File::open(path)?;
let reader = std::io::BufReader::new(file);
Self::parse(reader)
}
pub fn find_match(&self, attributes: &HashMap<String, String>) -> Option<&IndexEntry> {
self.entries.iter().find(|entry| entry.matches(&self.columns, attributes))
}
pub fn find_all_matches(&self, attributes: &HashMap<String, String>) -> Vec<&IndexEntry> {
self.entries
.iter()
.filter(|entry| entry.matches(&self.columns, attributes))
.collect()
}
pub fn columns(&self) -> &[String] {
&self.columns
}
pub fn entries(&self) -> &[IndexEntry] {
&self.entries
}
pub fn all_templates(&self) -> Vec<&str> {
let mut templates: Vec<&str> = Vec::new();
for entry in &self.entries {
for template in &entry.templates {
if !templates.contains(&template.as_str()) {
templates.push(template);
}
}
}
templates
}
}
#[derive(Debug, Clone)]
pub struct IndexEntry {
templates: Vec<String>,
patterns: Vec<Option<Regex>>,
raw_values: Vec<String>,
line_num: usize,
}
impl IndexEntry {
fn parse(columns: &[String], fields: Vec<String>, line_num: usize) -> Result<Self, CliTableError> {
let mut templates: Vec<String> = Vec::new();
let mut patterns: Vec<Option<Regex>> = Vec::new();
let mut raw_values: Vec<String> = Vec::new();
for (i, column) in columns.iter().enumerate() {
let value = fields.get(i).map(|s| s.trim().to_string()).unwrap_or_default();
raw_values.push(value.clone());
if column == "Template" {
templates = value
.split(':')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
patterns.push(None);
} else {
if value.is_empty() {
patterns.push(None);
} else {
let expanded = if column == "Command" {
expand_completion(&value)?
} else {
value.clone()
};
let anchored = if expanded.starts_with('^') {
expanded
} else {
format!("^{}", expanded)
};
let regex = Regex::new(&anchored).map_err(|e| CliTableError::InvalidRegex {
line: line_num,
message: format!("{}: {}", column, e),
})?;
patterns.push(Some(regex));
}
}
}
if templates.is_empty() {
return Err(CliTableError::IndexParse {
line: line_num,
message: "empty Template field".into(),
});
}
Ok(Self {
templates,
patterns,
raw_values,
line_num,
})
}
pub fn matches(&self, columns: &[String], attributes: &HashMap<String, String>) -> bool {
for (i, column) in columns.iter().enumerate() {
if column == "Template" {
continue;
}
if let Some(Some(pattern)) = self.patterns.get(i) {
let attr_value = attributes.get(column).map(|s| s.as_str()).unwrap_or("");
match pattern.is_match(attr_value) {
Ok(true) => continue,
Ok(false) => return false,
Err(_) => return false,
}
}
}
true
}
pub fn templates(&self) -> &[String] {
&self.templates
}
pub fn raw_values(&self) -> &[String] {
&self.raw_values
}
pub fn line_num(&self) -> usize {
self.line_num
}
}
fn parse_csv_line(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match c {
'"' if !in_quotes => {
in_quotes = true;
}
'"' if in_quotes => {
if chars.peek() == Some(&'"') {
chars.next();
current.push('"');
} else {
in_quotes = false;
}
}
',' if !in_quotes => {
fields.push(current.trim().to_string());
current = String::new();
}
_ => {
current.push(c);
}
}
}
fields.push(current.trim().to_string());
fields
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_index() {
let csv = r#"Template, Hostname, Command
template_a.textfsm, .*, show version
template_b.textfsm, .*, show interfaces
"#;
let index = Index::parse_str(csv).unwrap();
assert_eq!(index.columns().len(), 3);
assert_eq!(index.entries().len(), 2);
assert_eq!(index.entries()[0].templates(), &["template_a.textfsm"]);
assert_eq!(index.entries()[1].templates(), &["template_b.textfsm"]);
}
#[test]
fn test_parse_with_comments() {
let csv = r#"# This is a comment
Template, Command
# Another comment
template.textfsm, show version
"#;
let index = Index::parse_str(csv).unwrap();
assert_eq!(index.entries().len(), 1);
}
#[test]
fn test_parse_multi_template() {
let csv = r#"Template, Command
template_a.textfsm:template_b.textfsm, show version
"#;
let index = Index::parse_str(csv).unwrap();
assert_eq!(
index.entries()[0].templates(),
&["template_a.textfsm", "template_b.textfsm"]
);
}
#[test]
fn test_find_match() {
let csv = r#"Template, Platform, Command
cisco_show_version.textfsm, cisco_ios, show version
arista_show_version.textfsm, arista_eos, show version
cisco_show_interfaces.textfsm, cisco_ios, show interfaces
"#;
let index = Index::parse_str(csv).unwrap();
let mut attrs = HashMap::new();
attrs.insert("Platform".into(), "cisco_ios".into());
attrs.insert("Command".into(), "show version".into());
let entry = index.find_match(&attrs).unwrap();
assert_eq!(entry.templates(), &["cisco_show_version.textfsm"]);
}
#[test]
fn test_find_match_with_regex() {
let csv = r#"Template, Platform, Command
cisco_show_version.textfsm, cisco_.*, show version
"#;
let index = Index::parse_str(csv).unwrap();
let mut attrs = HashMap::new();
attrs.insert("Platform".into(), "cisco_ios".into());
attrs.insert("Command".into(), "show version".into());
let entry = index.find_match(&attrs);
assert!(entry.is_some());
attrs.insert("Platform".into(), "arista_eos".into());
let entry = index.find_match(&attrs);
assert!(entry.is_none());
}
#[test]
fn test_find_match_with_completion() {
let csv = r#"Template, Platform, Command
cisco_show_version.textfsm, cisco_ios, sh[[ow]] ver[[sion]]
"#;
let index = Index::parse_str(csv).unwrap();
let mut attrs = HashMap::new();
attrs.insert("Platform".into(), "cisco_ios".into());
attrs.insert("Command".into(), "show version".into());
assert!(index.find_match(&attrs).is_some(), "show version should match");
attrs.insert("Command".into(), "sh ver".into());
assert!(index.find_match(&attrs).is_some(), "sh ver should match");
attrs.insert("Command".into(), "sho vers".into());
assert!(index.find_match(&attrs).is_some(), "sho vers should match");
attrs.insert("Command".into(), "sh v".into());
assert!(index.find_match(&attrs).is_none(), "sh v should NOT match (ver is required)");
}
#[test]
fn test_missing_template_column() {
let csv = r#"Platform, Command
cisco_ios, show version
"#;
let result = Index::parse_str(csv);
assert!(matches!(result, Err(CliTableError::MissingColumn(_))));
}
#[test]
fn test_empty_template() {
let csv = r#"Template, Command
, show version
"#;
let result = Index::parse_str(csv);
assert!(matches!(result, Err(CliTableError::IndexParse { .. })));
}
#[test]
fn test_all_templates() {
let csv = r#"Template, Command
template_a.textfsm, show version
template_b.textfsm:template_c.textfsm, show interfaces
template_a.textfsm, show ip route
"#;
let index = Index::parse_str(csv).unwrap();
let templates = index.all_templates();
assert_eq!(templates.len(), 3);
assert!(templates.contains(&"template_a.textfsm"));
assert!(templates.contains(&"template_b.textfsm"));
assert!(templates.contains(&"template_c.textfsm"));
}
#[test]
fn test_csv_with_quotes() {
let csv = r#"Template, Command
template.textfsm, "show interfaces, all"
"#;
let index = Index::parse_str(csv).unwrap();
assert_eq!(index.entries()[0].raw_values()[1], "show interfaces, all");
}
}