1use 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#[derive(Debug, Clone)]
17pub struct Index {
18 columns: Vec<String>,
20
21 entries: Vec<IndexEntry>,
23}
24
25impl Index {
26 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 if trimmed.is_empty() || trimmed.starts_with('#') {
43 continue;
44 }
45
46 let fields: Vec<String> = parse_csv_line(trimmed);
48
49 if columns.is_empty() {
50 columns = fields.into_iter().map(|s| s.trim().to_string()).collect();
52
53 if !columns.iter().any(|c| c == "Template") {
55 return Err(CliTableError::MissingColumn("Template".into()));
56 }
57 } else {
58 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 pub fn parse_str(s: &str) -> Result<Self, CliTableError> {
76 Self::parse(s.as_bytes())
77 }
78
79 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 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 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 pub fn columns(&self) -> &[String] {
104 &self.columns
105 }
106
107 pub fn entries(&self) -> &[IndexEntry] {
109 &self.entries
110 }
111
112 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#[derive(Debug, Clone)]
128pub struct IndexEntry {
129 templates: Vec<String>,
131
132 patterns: Vec<Option<Regex>>,
135
136 raw_values: Vec<String>,
138
139 line_num: usize,
141}
142
143impl IndexEntry {
144 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 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 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 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 pub fn matches(&self, columns: &[String], attributes: &HashMap<String, String>) -> bool {
209 for (i, column) in columns.iter().enumerate() {
210 if column == "Template" {
212 continue;
213 }
214
215 if let Some(Some(pattern)) = self.patterns.get(i) {
217 let attr_value = attributes.get(column).map(|s| s.as_str()).unwrap_or("");
219
220 match pattern.is_match(attr_value) {
222 Ok(true) => continue,
223 Ok(false) => return false,
224 Err(_) => return false,
225 }
226 }
227 }
229
230 true
231 }
232
233 pub fn templates(&self) -> &[String] {
235 &self.templates
236 }
237
238 pub fn raw_values(&self) -> &[String] {
240 &self.raw_values
241 }
242
243 pub fn line_num(&self) -> usize {
245 self.line_num
246 }
247}
248
249fn 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 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 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 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 attrs.insert("Command".into(), "sh ver".into());
379 assert!(index.find_match(&attrs).is_some(), "sh ver should match");
380
381 attrs.insert("Command".into(), "sho vers".into());
383 assert!(index.find_match(&attrs).is_some(), "sho vers should match");
384
385 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}