mod completion;
mod error;
mod index;
mod table;
pub use completion::expand_completion;
pub use error::CliTableError;
pub use index::{Index, IndexEntry};
pub use table::{Row, TextTable};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use crate::Template;
pub struct CliTable {
index: Arc<Index>,
template_dir: PathBuf,
template_cache: RwLock<HashMap<PathBuf, Arc<Template>>>,
}
impl CliTable {
pub fn new<P1: AsRef<Path>, P2: AsRef<Path>>(
index_path: P1,
template_dir: P2,
) -> Result<Self, CliTableError> {
let index = Index::from_file(index_path)?;
Ok(Self {
index: Arc::new(index),
template_dir: template_dir.as_ref().to_path_buf(),
template_cache: RwLock::new(HashMap::new()),
})
}
pub fn from_index(index: Arc<Index>, template_dir: PathBuf) -> Self {
Self {
index,
template_dir,
template_cache: RwLock::new(HashMap::new()),
}
}
pub fn parse_cmd(
&self,
text: &str,
attributes: &HashMap<String, String>,
) -> Result<TextTable, CliTableError> {
let entry = self
.index
.find_match(attributes)
.ok_or_else(|| CliTableError::NoMatch(attributes.clone()))?;
self.parse_with_templates(text, entry.templates())
}
pub fn parse_with_templates(
&self,
text: &str,
template_names: &[String],
) -> Result<TextTable, CliTableError> {
if template_names.len() == 1 {
let template = self.load_template(&template_names[0])?;
let mut parser = template.parser();
let results = parser.parse_text(text)?;
let header: Vec<String> = template.header().iter().map(|s| s.to_string()).collect();
Ok(TextTable::from_values(header, results))
} else {
self.parse_and_merge(text, template_names)
}
}
pub fn find_templates(
&self,
attributes: &HashMap<String, String>,
) -> Result<Vec<PathBuf>, CliTableError> {
let entry = self
.index
.find_match(attributes)
.ok_or_else(|| CliTableError::NoMatch(attributes.clone()))?;
Ok(entry
.templates()
.iter()
.map(|name| self.template_dir.join(name))
.collect())
}
pub fn index(&self) -> &Index {
&self.index
}
pub fn clear_cache(&self) {
let mut cache = self.template_cache.write().unwrap();
cache.clear();
}
fn load_template(&self, name: &str) -> Result<Arc<Template>, CliTableError> {
let path = self.template_dir.join(name);
{
let cache = self.template_cache.read().unwrap();
if let Some(template) = cache.get(&path) {
return Ok(Arc::clone(template));
}
}
let content = std::fs::read_to_string(&path)
.map_err(|_| CliTableError::TemplateNotFound(path.clone()))?;
let template = Template::parse_str(&content)?;
let template = Arc::new(template);
{
let mut cache = self.template_cache.write().unwrap();
cache.insert(path, Arc::clone(&template));
}
Ok(template)
}
fn parse_and_merge(
&self,
text: &str,
template_names: &[String],
) -> Result<TextTable, CliTableError> {
use crate::types::ValueOption;
use indexmap::IndexSet;
type TemplateResults = (Arc<Template>, Vec<Vec<crate::Value>>, IndexSet<String>);
let mut all_results: Vec<TemplateResults> = Vec::new();
for name in template_names {
let template = self.load_template(name)?;
let mut parser = template.parser();
let results = parser.parse_text(text)?;
let template_keys: IndexSet<String> = template
.values()
.iter()
.filter(|v| v.has_option(ValueOption::Key))
.map(|v| v.name.clone())
.collect();
all_results.push((template, results, template_keys));
}
let shared_keys: IndexSet<String> = if all_results.is_empty() {
IndexSet::new()
} else {
let first_keys = &all_results[0].2;
all_results
.iter()
.skip(1)
.fold(first_keys.clone(), |acc, (_, _, keys)| {
acc.intersection(keys).cloned().collect()
})
};
if all_results.len() == 1 || shared_keys.is_empty() {
let (template, results, _) = all_results.into_iter().next().unwrap();
let header: Vec<String> = template.header().iter().map(|s| s.to_string()).collect();
return Ok(TextTable::from_values(header, results));
}
let mut unified_header: Vec<String> = Vec::new();
let mut header_index: HashMap<String, usize> = HashMap::new();
for (template, _, _) in &all_results {
for name in template.header() {
if let std::collections::hash_map::Entry::Vacant(e) = header_index.entry(name.to_string()) {
e.insert(unified_header.len());
unified_header.push(name.to_string());
}
}
}
let mut all_results_iter = all_results.into_iter();
let (first_template, first_results, _) = all_results_iter.next().unwrap();
let first_header: Vec<&str> = first_template.header();
let mut merged: indexmap::IndexMap<Vec<String>, Vec<crate::Value>> = indexmap::IndexMap::new();
for row in first_results {
let key = extract_key(&row, &first_header, &shared_keys);
let mut unified_row = vec![crate::Value::Empty; unified_header.len()];
for (i, value) in row.into_iter().enumerate() {
if i < first_header.len()
&& let Some(&unified_idx) = header_index.get(first_header[i])
{
unified_row[unified_idx] = value;
}
}
merged.insert(key, unified_row);
}
for (template, results, _) in all_results_iter {
let template_header: Vec<&str> = template.header();
for row in results {
let key = extract_key(&row, &template_header, &shared_keys);
if let Some(merged_row) = merged.get_mut(&key) {
for (i, value) in row.into_iter().enumerate() {
if !value.is_empty()
&& i < template_header.len()
&& let Some(&unified_idx) = header_index.get(template_header[i])
&& merged_row[unified_idx].is_empty()
{
merged_row[unified_idx] = value;
}
}
}
}
}
let rows: Vec<Vec<crate::Value>> = merged.into_values().collect();
let mut table = TextTable::from_values(unified_header, rows);
let superkey: Vec<String> = shared_keys.into_iter().collect();
table.set_superkey(superkey);
table.sort();
Ok(table)
}
}
fn extract_key(
row: &[crate::Value],
header: &[&str],
shared_keys: &indexmap::IndexSet<String>,
) -> Vec<String> {
shared_keys
.iter()
.map(|key_col| {
header
.iter()
.position(|h| *h == key_col)
.and_then(|idx| row.get(idx))
.map(|v| normalize_key_value(&v.as_string()))
.unwrap_or_default()
})
.collect()
}
fn normalize_key_value(s: &str) -> String {
let trimmed = s.trim();
if let Ok(n) = trimmed.parse::<i64>() {
return n.to_string();
}
trimmed.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::thread;
fn _assert_send_sync() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<CliTable>();
assert_sync::<CliTable>();
}
#[test]
fn test_concurrent_parsing() {
let temp_dir = std::env::temp_dir().join("textfsm_concurrency_test");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let template_content = r#"Value Name (\S+)
Value Age (\d+)
Start
^Name: ${Name}, Age: ${Age} -> Record
"#;
std::fs::write(temp_dir.join("test_template.textfsm"), template_content).unwrap();
let index_content = "Template, Platform, Command\ntest_template.textfsm, .*, show users\n";
std::fs::write(temp_dir.join("index"), index_content).unwrap();
let cli_table = Arc::new(
CliTable::new(temp_dir.join("index"), &temp_dir).expect("failed to create CliTable"),
);
let input = "Name: Alice, Age: 30\nName: Bob, Age: 25\nName: Charlie, Age: 35\n";
let num_threads = 8;
let iterations_per_thread = 100;
let handles: Vec<_> = (0..num_threads)
.map(|thread_id| {
let cli_table = Arc::clone(&cli_table);
let input = input.to_string();
thread::spawn(move || {
let mut attrs = HashMap::new();
attrs.insert("Platform".to_string(), "test".to_string());
attrs.insert("Command".to_string(), "show users".to_string());
for i in 0..iterations_per_thread {
let result = cli_table.parse_cmd(&input, &attrs);
match result {
Ok(table) => {
assert_eq!(table.len(), 3, "thread {} iter {}: wrong row count", thread_id, i);
}
Err(e) => {
panic!("thread {} iter {}: parse failed: {}", thread_id, i, e);
}
}
}
thread_id
})
})
.collect();
let mut completed = Vec::new();
for handle in handles {
let thread_id = handle.join().expect("thread panicked");
completed.push(thread_id);
}
assert_eq!(completed.len(), num_threads);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_concurrent_parsing_different_templates() {
let temp_dir = std::env::temp_dir().join("textfsm_concurrency_test_multi");
let _ = std::fs::remove_dir_all(&temp_dir);
std::fs::create_dir_all(&temp_dir).unwrap();
let template_a = r#"Value Interface (\S+)
Value Status (up|down)
Start
^${Interface} is ${Status} -> Record
"#;
std::fs::write(temp_dir.join("template_a.textfsm"), template_a).unwrap();
let template_b = r#"Value Version (\S+)
Value Uptime (\d+)
Start
^Version: ${Version}, Uptime: ${Uptime} -> Record
"#;
std::fs::write(temp_dir.join("template_b.textfsm"), template_b).unwrap();
let index_content = r#"Template, Platform, Command
template_a.textfsm, .*, show interfaces
template_b.textfsm, .*, show version
"#;
std::fs::write(temp_dir.join("index"), index_content).unwrap();
let cli_table = Arc::new(
CliTable::new(temp_dir.join("index"), &temp_dir).expect("failed to create CliTable"),
);
let input_a = "eth0 is up\neth1 is down\n";
let input_b = "Version: 1.2.3, Uptime: 3600\n";
let num_threads = 4;
let handles: Vec<_> = (0..num_threads)
.map(|thread_id| {
let cli_table = Arc::clone(&cli_table);
let input_a = input_a.to_string();
let input_b = input_b.to_string();
thread::spawn(move || {
for i in 0..50 {
if (thread_id + i) % 2 == 0 {
let mut attrs = HashMap::new();
attrs.insert("Platform".to_string(), "test".to_string());
attrs.insert("Command".to_string(), "show interfaces".to_string());
let table = cli_table.parse_cmd(&input_a, &attrs).unwrap();
assert_eq!(table.len(), 2);
} else {
let mut attrs = HashMap::new();
attrs.insert("Platform".to_string(), "test".to_string());
attrs.insert("Command".to_string(), "show version".to_string());
let table = cli_table.parse_cmd(&input_b, &attrs).unwrap();
assert_eq!(table.len(), 1);
}
}
thread_id
})
})
.collect();
for handle in handles {
handle.join().expect("thread panicked");
}
let _ = std::fs::remove_dir_all(&temp_dir);
}
}
#[cfg(feature = "serde")]
impl CliTable {
pub fn parse_cmd_into<T>(
&self,
text: &str,
attributes: &HashMap<String, String>,
) -> Result<Vec<T>, CliTableError>
where
T: serde::de::DeserializeOwned,
{
let table = self.parse_cmd(text, attributes)?;
table.into_deserialize()
}
}