Skip to main content

textfsm_core/clitable/
mod.rs

1//! CliTable - Index-based template selection for parsing CLI output.
2//!
3//! CliTable provides automatic template selection based on command/platform
4//! attributes, supporting the ntc-templates index format.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use textfsm_core::{CliTable, HashMap};
10//!
11//! let cli_table = CliTable::new("templates/index", "templates/")?;
12//!
13//! let mut attrs = HashMap::new();
14//! attrs.insert("Command".into(), "show interfaces".into());
15//! attrs.insert("Platform".into(), "cisco_ios".into());
16//!
17//! let table = cli_table.parse_cmd(cli_output, &attrs)?;
18//! ```
19
20mod completion;
21mod error;
22mod index;
23mod table;
24
25pub use completion::expand_completion;
26pub use error::CliTableError;
27pub use index::{Index, IndexEntry};
28pub use table::{Row, TextTable};
29
30use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32use std::sync::{Arc, RwLock};
33
34use crate::Template;
35
36/// Index-based template selection and parsing for CLI output.
37///
38/// `CliTable` is `Send + Sync` and can be safely shared across threads.
39pub struct CliTable {
40    /// Parsed index file.
41    index: Arc<Index>,
42
43    /// Directory containing template files.
44    template_dir: PathBuf,
45
46    /// Cache of parsed templates.
47    template_cache: RwLock<HashMap<PathBuf, Arc<Template>>>,
48}
49
50impl CliTable {
51    /// Create a new CliTable from an index file.
52    ///
53    /// # Arguments
54    ///
55    /// * `index_path` - Path to the index file (CSV format)
56    /// * `template_dir` - Directory containing template files
57    pub fn new<P1: AsRef<Path>, P2: AsRef<Path>>(
58        index_path: P1,
59        template_dir: P2,
60    ) -> Result<Self, CliTableError> {
61        let index = Index::from_file(index_path)?;
62        Ok(Self {
63            index: Arc::new(index),
64            template_dir: template_dir.as_ref().to_path_buf(),
65            template_cache: RwLock::new(HashMap::new()),
66        })
67    }
68
69    /// Create a CliTable from an already-parsed index.
70    pub fn from_index(index: Arc<Index>, template_dir: PathBuf) -> Self {
71        Self {
72            index,
73            template_dir,
74            template_cache: RwLock::new(HashMap::new()),
75        }
76    }
77
78    /// Parse CLI output using attribute-based template selection.
79    ///
80    /// # Arguments
81    ///
82    /// * `text` - CLI output to parse
83    /// * `attributes` - Key-value pairs for template matching (e.g., "Command", "Platform")
84    pub fn parse_cmd(
85        &self,
86        text: &str,
87        attributes: &HashMap<String, String>,
88    ) -> Result<TextTable, CliTableError> {
89        let entry = self
90            .index
91            .find_match(attributes)
92            .ok_or_else(|| CliTableError::NoMatch(attributes.clone()))?;
93
94        self.parse_with_templates(text, entry.templates())
95    }
96
97    /// Parse CLI output with explicit template names.
98    pub fn parse_with_templates(
99        &self,
100        text: &str,
101        template_names: &[String],
102    ) -> Result<TextTable, CliTableError> {
103        if template_names.len() == 1 {
104            // Single template - simple case
105            let template = self.load_template(&template_names[0])?;
106            let mut parser = template.parser();
107            let results = parser.parse_text(text)?;
108            let header: Vec<String> = template.header().iter().map(|s| s.to_string()).collect();
109            Ok(TextTable::from_values(header, results))
110        } else {
111            // Multiple templates - need to merge results
112            self.parse_and_merge(text, template_names)
113        }
114    }
115
116    /// Find the matching template paths for given attributes.
117    pub fn find_templates(
118        &self,
119        attributes: &HashMap<String, String>,
120    ) -> Result<Vec<PathBuf>, CliTableError> {
121        let entry = self
122            .index
123            .find_match(attributes)
124            .ok_or_else(|| CliTableError::NoMatch(attributes.clone()))?;
125
126        Ok(entry
127            .templates()
128            .iter()
129            .map(|name| self.template_dir.join(name))
130            .collect())
131    }
132
133    /// Get the index.
134    pub fn index(&self) -> &Index {
135        &self.index
136    }
137
138    /// Clear the template cache.
139    pub fn clear_cache(&self) {
140        let mut cache = self.template_cache.write().unwrap();
141        cache.clear();
142    }
143
144    /// Load a template by name, using the cache.
145    fn load_template(&self, name: &str) -> Result<Arc<Template>, CliTableError> {
146        let path = self.template_dir.join(name);
147
148        // Check cache first
149        {
150            let cache = self.template_cache.read().unwrap();
151            if let Some(template) = cache.get(&path) {
152                return Ok(Arc::clone(template));
153            }
154        }
155
156        // Load and parse template
157        let content = std::fs::read_to_string(&path)
158            .map_err(|_| CliTableError::TemplateNotFound(path.clone()))?;
159        let template = Template::parse_str(&content)?;
160        let template = Arc::new(template);
161
162        // Cache it
163        {
164            let mut cache = self.template_cache.write().unwrap();
165            cache.insert(path, Arc::clone(&template));
166        }
167
168        Ok(template)
169    }
170
171    /// Parse with multiple templates and merge results.
172    ///
173    /// This function handles multi-template parsing where the index specifies
174    /// multiple templates separated by colons (e.g., `template_a:template_b`).
175    ///
176    /// The merge strategy:
177    /// 1. Parse all templates and collect their results
178    /// 2. Find Key columns that exist in ALL templates (intersection)
179    /// 3. Use the first template's results as the "master" rows
180    /// 4. Merge data from subsequent templates into matching master rows
181    /// 5. Sort by the shared key columns
182    fn parse_and_merge(
183        &self,
184        text: &str,
185        template_names: &[String],
186    ) -> Result<TextTable, CliTableError> {
187        use crate::types::ValueOption;
188        use indexmap::IndexSet;
189
190        // Type alias for parsed template results: (template, parsed rows, key columns)
191        type TemplateResults = (Arc<Template>, Vec<Vec<crate::Value>>, IndexSet<String>);
192
193        // Step 1: Parse each template and collect results + key columns
194        let mut all_results: Vec<TemplateResults> = Vec::new();
195
196        for name in template_names {
197            let template = self.load_template(name)?;
198            let mut parser = template.parser();
199            let results = parser.parse_text(text)?;
200
201            // Collect key columns from this template (ordered)
202            let template_keys: IndexSet<String> = template
203                .values()
204                .iter()
205                .filter(|v| v.has_option(ValueOption::Key))
206                .map(|v| v.name.clone())
207                .collect();
208
209            all_results.push((template, results, template_keys));
210        }
211
212        // Step 2: Find shared key columns (intersection of all templates' keys)
213        // Use IndexSet to maintain consistent ordering
214        let shared_keys: IndexSet<String> = if all_results.is_empty() {
215            IndexSet::new()
216        } else {
217            let first_keys = &all_results[0].2;
218            all_results
219                .iter()
220                .skip(1)
221                .fold(first_keys.clone(), |acc, (_, _, keys)| {
222                    acc.intersection(keys).cloned().collect()
223                })
224        };
225
226        // If only one template or no shared key columns, just use first template's results
227        if all_results.len() == 1 || shared_keys.is_empty() {
228            let (template, results, _) = all_results.into_iter().next().unwrap();
229            let header: Vec<String> = template.header().iter().map(|s| s.to_string()).collect();
230            return Ok(TextTable::from_values(header, results));
231        }
232
233        // Step 3: Build unified header from all templates (preserving order)
234        let mut unified_header: Vec<String> = Vec::new();
235        let mut header_index: HashMap<String, usize> = HashMap::new();
236
237        for (template, _, _) in &all_results {
238            for name in template.header() {
239                if let std::collections::hash_map::Entry::Vacant(e) = header_index.entry(name.to_string()) {
240                    e.insert(unified_header.len());
241                    unified_header.push(name.to_string());
242                }
243            }
244        }
245
246        // Step 4: Use first template as master, merge subsequent templates
247        let mut all_results_iter = all_results.into_iter();
248        let (first_template, first_results, _) = all_results_iter.next().unwrap();
249        let first_header: Vec<&str> = first_template.header();
250
251        // Initialize merged map with first template's rows
252        // Use IndexMap to preserve insertion order
253        let mut merged: indexmap::IndexMap<Vec<String>, Vec<crate::Value>> = indexmap::IndexMap::new();
254
255        for row in first_results {
256            let key = extract_key(&row, &first_header, &shared_keys);
257            let mut unified_row = vec![crate::Value::Empty; unified_header.len()];
258
259            // Copy values from first template to unified row
260            for (i, value) in row.into_iter().enumerate() {
261                if i < first_header.len()
262                    && let Some(&unified_idx) = header_index.get(first_header[i])
263                {
264                    unified_row[unified_idx] = value;
265                }
266            }
267
268            merged.insert(key, unified_row);
269        }
270
271        // Merge subsequent templates into existing rows only (don't create new rows)
272        for (template, results, _) in all_results_iter {
273            let template_header: Vec<&str> = template.header();
274
275            for row in results {
276                let key = extract_key(&row, &template_header, &shared_keys);
277
278                // Only merge into existing rows from the master template
279                if let Some(merged_row) = merged.get_mut(&key) {
280                    // Copy non-empty values to unified positions
281                    for (i, value) in row.into_iter().enumerate() {
282                        if !value.is_empty()
283                            && i < template_header.len()
284                            && let Some(&unified_idx) = header_index.get(template_header[i])
285                            && merged_row[unified_idx].is_empty()
286                        {
287                            merged_row[unified_idx] = value;
288                        }
289                    }
290                }
291                // If key doesn't exist in master, we skip this row (don't create extra rows)
292            }
293        }
294
295        // Step 5: Convert to TextTable and sort
296        let rows: Vec<Vec<crate::Value>> = merged.into_values().collect();
297        let mut table = TextTable::from_values(unified_header, rows);
298
299        // Set superkey for sorting (shared keys in deterministic order)
300        let superkey: Vec<String> = shared_keys.into_iter().collect();
301        table.set_superkey(superkey);
302        table.sort();
303
304        Ok(table)
305    }
306}
307
308/// Extract a key from a row based on the shared key columns.
309/// Normalizes key values for consistent matching.
310fn extract_key(
311    row: &[crate::Value],
312    header: &[&str],
313    shared_keys: &indexmap::IndexSet<String>,
314) -> Vec<String> {
315    shared_keys
316        .iter()
317        .map(|key_col| {
318            header
319                .iter()
320                .position(|h| *h == key_col)
321                .and_then(|idx| row.get(idx))
322                .map(|v| normalize_key_value(&v.as_string()))
323                .unwrap_or_default()
324        })
325        .collect()
326}
327
328/// Normalize a key value for consistent matching.
329/// - Trims whitespace
330/// - Removes leading zeros from pure numeric values
331fn normalize_key_value(s: &str) -> String {
332    let trimmed = s.trim();
333
334    // If it's a pure integer, normalize by parsing and reformatting
335    // This handles cases like "01" vs "1"
336    if let Ok(n) = trimmed.parse::<i64>() {
337        return n.to_string();
338    }
339
340    trimmed.to_string()
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::sync::Arc;
347    use std::thread;
348
349    // Verify Send + Sync at compile time
350    fn _assert_send_sync() {
351        fn assert_send<T: Send>() {}
352        fn assert_sync<T: Sync>() {}
353        assert_send::<CliTable>();
354        assert_sync::<CliTable>();
355    }
356
357    #[test]
358    fn test_concurrent_parsing() {
359        // Create a temporary index and template
360        let temp_dir = std::env::temp_dir().join("textfsm_concurrency_test");
361        let _ = std::fs::remove_dir_all(&temp_dir);
362        std::fs::create_dir_all(&temp_dir).unwrap();
363
364        // Write a simple template
365        let template_content = r#"Value Name (\S+)
366Value Age (\d+)
367
368Start
369  ^Name: ${Name}, Age: ${Age} -> Record
370"#;
371        std::fs::write(temp_dir.join("test_template.textfsm"), template_content).unwrap();
372
373        // Write index file
374        let index_content = "Template, Platform, Command\ntest_template.textfsm, .*, show users\n";
375        std::fs::write(temp_dir.join("index"), index_content).unwrap();
376
377        // Create CliTable and wrap in Arc for sharing
378        let cli_table = Arc::new(
379            CliTable::new(temp_dir.join("index"), &temp_dir).expect("failed to create CliTable"),
380        );
381
382        // Test input
383        let input = "Name: Alice, Age: 30\nName: Bob, Age: 25\nName: Charlie, Age: 35\n";
384
385        // Spawn multiple threads that parse concurrently
386        let num_threads = 8;
387        let iterations_per_thread = 100;
388
389        let handles: Vec<_> = (0..num_threads)
390            .map(|thread_id| {
391                let cli_table = Arc::clone(&cli_table);
392                let input = input.to_string();
393
394                thread::spawn(move || {
395                    let mut attrs = HashMap::new();
396                    attrs.insert("Platform".to_string(), "test".to_string());
397                    attrs.insert("Command".to_string(), "show users".to_string());
398
399                    for i in 0..iterations_per_thread {
400                        let result = cli_table.parse_cmd(&input, &attrs);
401                        match result {
402                            Ok(table) => {
403                                assert_eq!(table.len(), 3, "thread {} iter {}: wrong row count", thread_id, i);
404                            }
405                            Err(e) => {
406                                panic!("thread {} iter {}: parse failed: {}", thread_id, i, e);
407                            }
408                        }
409                    }
410                    thread_id
411                })
412            })
413            .collect();
414
415        // Wait for all threads to complete
416        let mut completed = Vec::new();
417        for handle in handles {
418            let thread_id = handle.join().expect("thread panicked");
419            completed.push(thread_id);
420        }
421
422        assert_eq!(completed.len(), num_threads);
423
424        // Cleanup
425        let _ = std::fs::remove_dir_all(&temp_dir);
426    }
427
428    #[test]
429    fn test_concurrent_parsing_different_templates() {
430        // Create a temporary directory with multiple templates
431        let temp_dir = std::env::temp_dir().join("textfsm_concurrency_test_multi");
432        let _ = std::fs::remove_dir_all(&temp_dir);
433        std::fs::create_dir_all(&temp_dir).unwrap();
434
435        // Write template A
436        let template_a = r#"Value Interface (\S+)
437Value Status (up|down)
438
439Start
440  ^${Interface} is ${Status} -> Record
441"#;
442        std::fs::write(temp_dir.join("template_a.textfsm"), template_a).unwrap();
443
444        // Write template B
445        let template_b = r#"Value Version (\S+)
446Value Uptime (\d+)
447
448Start
449  ^Version: ${Version}, Uptime: ${Uptime} -> Record
450"#;
451        std::fs::write(temp_dir.join("template_b.textfsm"), template_b).unwrap();
452
453        // Write index file with both templates
454        let index_content = r#"Template, Platform, Command
455template_a.textfsm, .*, show interfaces
456template_b.textfsm, .*, show version
457"#;
458        std::fs::write(temp_dir.join("index"), index_content).unwrap();
459
460        let cli_table = Arc::new(
461            CliTable::new(temp_dir.join("index"), &temp_dir).expect("failed to create CliTable"),
462        );
463
464        let input_a = "eth0 is up\neth1 is down\n";
465        let input_b = "Version: 1.2.3, Uptime: 3600\n";
466
467        let num_threads = 4;
468
469        let handles: Vec<_> = (0..num_threads)
470            .map(|thread_id| {
471                let cli_table = Arc::clone(&cli_table);
472                let input_a = input_a.to_string();
473                let input_b = input_b.to_string();
474
475                thread::spawn(move || {
476                    // Alternate between template A and B
477                    for i in 0..50 {
478                        if (thread_id + i) % 2 == 0 {
479                            let mut attrs = HashMap::new();
480                            attrs.insert("Platform".to_string(), "test".to_string());
481                            attrs.insert("Command".to_string(), "show interfaces".to_string());
482
483                            let table = cli_table.parse_cmd(&input_a, &attrs).unwrap();
484                            assert_eq!(table.len(), 2);
485                        } else {
486                            let mut attrs = HashMap::new();
487                            attrs.insert("Platform".to_string(), "test".to_string());
488                            attrs.insert("Command".to_string(), "show version".to_string());
489
490                            let table = cli_table.parse_cmd(&input_b, &attrs).unwrap();
491                            assert_eq!(table.len(), 1);
492                        }
493                    }
494                    thread_id
495                })
496            })
497            .collect();
498
499        for handle in handles {
500            handle.join().expect("thread panicked");
501        }
502
503        // Cleanup
504        let _ = std::fs::remove_dir_all(&temp_dir);
505    }
506}
507
508#[cfg(feature = "serde")]
509impl CliTable {
510    /// Parse CLI output and deserialize directly into typed structs.
511    pub fn parse_cmd_into<T>(
512        &self,
513        text: &str,
514        attributes: &HashMap<String, String>,
515    ) -> Result<Vec<T>, CliTableError>
516    where
517        T: serde::de::DeserializeOwned,
518    {
519        let table = self.parse_cmd(text, attributes)?;
520        table.into_deserialize()
521    }
522}