vault_audit_tools/commands/
kv_compare.rs1use crate::utils::format::format_number;
46use anyhow::{Context, Result};
47use std::collections::HashSet;
48use std::fs::File;
49
50struct MountData {
52 operations: usize,
53 paths: usize,
54 entities: HashSet<String>,
55}
56
57fn analyze_mount(csvfile: &str) -> Result<Option<MountData>> {
58 let Ok(file) = File::open(csvfile) else {
59 return Ok(None);
60 };
61
62 let mut reader = csv::Reader::from_reader(file);
63 let mut operations = 0;
64 let mut paths = 0;
65 let mut entities: HashSet<String> = HashSet::new();
66
67 for result in reader.records() {
68 let record = result?;
69
70 if let Some(ops_str) = record.get(2) {
72 if let Ok(ops) = ops_str.parse::<usize>() {
73 operations += ops;
74 }
75 }
76
77 paths += 1;
78
79 if let Some(entity_ids_str) = record.get(3) {
81 for eid in entity_ids_str.split(',') {
82 let trimmed = eid.trim();
83 if !trimmed.is_empty() {
84 entities.insert(trimmed.to_string());
85 }
86 }
87 }
88 }
89
90 if paths == 0 {
91 return Ok(None);
92 }
93
94 Ok(Some(MountData {
95 operations,
96 paths,
97 entities,
98 }))
99}
100
101pub fn run(csv1: &str, csv2: &str) -> Result<()> {
102 let csv_files = vec![csv1.to_string(), csv2.to_string()];
103
104 println!("{}", "=".repeat(95));
105 println!(
106 "{:<20} {:<18} {:<18} {:<20}",
107 "KV Mount", "Operations", "Unique Paths", "Unique Entities"
108 );
109 println!("{}", "=".repeat(95));
110
111 let mut results = Vec::new();
112 let mut total_ops = 0;
113 let mut total_paths = 0;
114 let mut all_entities: HashSet<String> = HashSet::new();
115
116 for csv_file in &csv_files {
117 let mount_name = std::path::Path::new(csv_file)
119 .file_stem()
120 .and_then(|s| s.to_str())
121 .unwrap_or(csv_file);
122
123 match analyze_mount(csv_file).context(format!("Failed to analyze {}", csv_file))? {
124 Some(data) => {
125 println!(
126 "{:<20} {:<18} {:<18} {:<20}",
127 mount_name,
128 format_number(data.operations),
129 format_number(data.paths),
130 format_number(data.entities.len())
131 );
132
133 total_ops += data.operations;
134 total_paths += data.paths;
135 all_entities.extend(data.entities.iter().cloned());
136
137 results.push((mount_name.to_string(), data));
138 }
139 None => {
140 println!("{:<20} {:<18}", mount_name, "(file not found)");
141 }
142 }
143 }
144
145 println!("{}", "=".repeat(95));
146 println!(
147 "{:<20} {:<18} {:<18} {:<20}",
148 "TOTAL",
149 format_number(total_ops),
150 format_number(total_paths),
151 format_number(all_entities.len())
152 );
153 println!("{}", "=".repeat(95));
154
155 if !results.is_empty() {
157 println!("\nPercentage Breakdown by Operations:");
158 println!("{}", "-".repeat(50));
159
160 results.sort_by(|a, b| b.1.operations.cmp(&a.1.operations));
162
163 for (mount, data) in results {
164 let pct = if total_ops > 0 {
165 (data.operations as f64 / total_ops as f64) * 100.0
166 } else {
167 0.0
168 };
169 println!("{:<20} {:>6.2}%", mount, pct);
170 }
171 }
172
173 Ok(())
174}