1use std::collections::BTreeMap;
31use std::io;
32use std::process::ExitCode;
33
34use kevy_resp_client::{Reply, RespClient};
35
36pub struct Overlap {
38 pub owners: usize,
40 pub names: usize,
42 pub shared: usize,
44 pub examples: Vec<(String, Vec<String>)>,
46 pub skipped: usize,
50}
51
52pub fn overlap(client: &mut RespClient, prefix: &str) -> io::Result<Overlap> {
54 let keys = crate::collections::scan_prefix(client, prefix)?;
55 let mut owners_of: BTreeMap<Vec<u8>, Vec<String>> = BTreeMap::new();
56 let (mut owners, mut skipped) = (0usize, 0usize);
57 for k in &keys {
58 let owner = String::from_utf8_lossy(k).into_owned();
59 let Some(ms) = crate::collections::members_if_collection(client, &owner)? else {
62 skipped += 1;
63 continue;
64 };
65 owners += 1;
66 for m in ms {
67 owners_of.entry(m).or_default().push(owner.clone());
68 }
69 }
70 let mut o = tally(owners, &owners_of);
71 o.skipped = skipped;
72 Ok(o)
73}
74
75fn tally(owners: usize, owners_of: &BTreeMap<Vec<u8>, Vec<String>>) -> Overlap {
78 let shared: Vec<_> = owners_of.iter().filter(|(_, o)| o.len() > 1).collect();
79 Overlap {
80 owners,
81 skipped: 0,
82 names: owners_of.len(),
83 shared: shared.len(),
84 examples: shared
85 .iter()
86 .take(5)
87 .map(|(n, o)| (String::from_utf8_lossy(n).into_owned(), (*o).clone()))
88 .collect(),
89 }
90}
91
92pub struct Coincidence {
94 pub a: String,
96 pub b: String,
98 pub same: usize,
100 pub compared: usize,
102}
103
104impl Coincidence {
105 pub fn percent(&self) -> u32 {
107 (self.same * 100).checked_div(self.compared).unwrap_or(0) as u32
108 }
109}
110
111pub fn column_pairs(
114 client: &mut RespClient,
115 prefix: &str,
116 sample: usize,
117 threshold: u32,
118) -> io::Result<(usize, Vec<Coincidence>)> {
119 let keys = crate::collections::scan_prefix(client, prefix)?;
120 let mut rows = Vec::new();
121 for k in keys.iter().take(sample) {
122 let row = hgetall(client, k)?;
123 if !row.is_empty() {
124 rows.push(row);
125 }
126 }
127 Ok((rows.len(), coincidences(&rows, threshold)))
128}
129
130fn coincidences(rows: &[BTreeMap<String, Vec<u8>>], threshold: u32) -> Vec<Coincidence> {
133 let mut pairs: BTreeMap<(String, String), (usize, usize)> = BTreeMap::new();
134 for row in rows {
135 let cols: Vec<&String> = row.keys().collect();
136 for (i, a) in cols.iter().enumerate() {
137 for b in &cols[i + 1..] {
138 let e = pairs.entry(((*a).clone(), (*b).clone())).or_insert((0, 0));
139 e.1 += 1;
140 if row[*a] == row[*b] {
141 e.0 += 1;
142 }
143 }
144 }
145 }
146 let mut out: Vec<Coincidence> = pairs
147 .into_iter()
148 .map(|((a, b), (same, compared))| Coincidence { a, b, same, compared })
149 .filter(|c| c.percent() >= threshold)
150 .collect();
151 out.sort_by(|x, y| y.percent().cmp(&x.percent()).then(x.a.cmp(&y.a)));
152 out
153}
154
155fn hgetall(client: &mut RespClient, key: &[u8]) -> io::Result<BTreeMap<String, Vec<u8>>> {
156 let reply = client.request_borrowed(&[b"HGETALL", key])?;
157 let flat = crate::collections::bulks(reply);
158 Ok(flat
159 .chunks(2)
160 .filter(|c| c.len() == 2)
161 .map(|c| (String::from_utf8_lossy(&c[0]).into_owned(), c[1].clone()))
162 .collect())
163}
164
165fn table_prefix(client: &mut RespClient, table: &str) -> io::Result<String> {
167 let Reply::Array(tables) = client.request_borrowed(&[b"TABLE.LIST"])? else {
168 return Err(io::Error::other("TABLE.LIST did not answer with a list"));
169 };
170 for t in &tables {
171 let Reply::Array(items) = t else { continue };
172 let f = crate::doctor::fields(items);
173 let named = f.iter().any(|(k, v)| k == "name" && v == table);
174 if named && let Some((_, p)) = f.iter().find(|(k, _)| k == "prefix") {
175 return Ok(p.clone());
176 }
177 }
178 Err(io::Error::other(format!("no declared table named '{table}'")))
179}
180
181pub fn run_lint_cli(args: &[String]) -> ExitCode {
184 let (mut host, mut port) = (crate::DEFAULT_HOST.to_string(), crate::DEFAULT_PORT);
185 let (mut prefix, mut table) = (String::new(), String::new());
186 let (mut sample, mut threshold) = (1000usize, 90u32);
187 let sub = args.first().cloned().unwrap_or_default();
188 let mut i = 1;
189 while i < args.len() {
190 let val = args.get(i + 1);
191 match (args[i].as_str(), val) {
192 ("-h", Some(v)) => host = v.clone(),
193 ("-p", Some(v)) => port = v.parse().unwrap_or(crate::DEFAULT_PORT),
194 ("--prefix", Some(v)) => prefix = v.clone(),
195 ("--sample", Some(v)) => sample = v.parse().unwrap_or(sample),
196 ("--threshold", Some(v)) => threshold = v.parse().unwrap_or(threshold),
197 (other, _) if !other.starts_with('-') && table.is_empty() => {
198 table = other.to_string();
199 i += 1;
200 continue;
201 }
202 _ => {
203 i += 1;
204 continue;
205 }
206 }
207 i += 2;
208 }
209 let mut client = match RespClient::connect(&host, port) {
210 Ok(c) => c,
211 Err(e) => {
212 eprintln!("kevy-cli lint: could not connect to {host}:{port}: {e}");
213 return ExitCode::FAILURE;
214 }
215 };
216 match sub.as_str() {
217 "overlap" => run_overlap(&mut client, &prefix),
218 "columns" => run_columns(&mut client, &table, sample, threshold),
219 other => {
220 eprintln!("kevy-cli lint: unknown subcommand '{other}'");
221 eprintln!("usage: kevy-cli lint overlap --prefix <p>");
222 eprintln!(" kevy-cli lint columns <table> [--sample N] [--threshold PCT]");
223 ExitCode::FAILURE
224 }
225 }
226}
227
228fn run_overlap(client: &mut RespClient, prefix: &str) -> ExitCode {
232 if prefix.is_empty() {
233 eprintln!("kevy-cli lint overlap: --prefix names the family of owner keys");
234 return ExitCode::FAILURE;
235 }
236 let o = match overlap(client, prefix) {
237 Ok(o) => o,
238 Err(e) => {
239 eprintln!("kevy-cli lint overlap: {e}");
240 return ExitCode::FAILURE;
241 }
242 };
243 println!("{} owner(s) under {prefix}, {} distinct name(s)", o.owners, o.names);
244 if o.skipped > 0 {
245 println!(" ({} key(s) under this prefix are not collections and were skipped)", o.skipped);
246 }
247 if o.owners == 0 {
248 println!("no collection under {prefix} — is that the right prefix?");
249 return ExitCode::FAILURE;
250 }
251 if o.shared == 0 {
252 println!("no name appears under more than one owner — a column can carry this dimension");
253 return ExitCode::SUCCESS;
254 }
255 println!("{} name(s) appear under more than one owner:", o.shared);
256 for (name, owners) in &o.examples {
257 println!(" {name} → {}", owners.join(", "));
258 }
259 println!(
260 "this dimension is multi-valued, so no column can hold it — model a membership row \
261 per (owner, item) and let an ORDERPATH sort it"
262 );
263 ExitCode::FAILURE
264}
265
266fn run_columns(client: &mut RespClient, table: &str, sample: usize, threshold: u32) -> ExitCode {
269 if table.is_empty() {
270 eprintln!("kevy-cli lint columns: name a declared table");
271 return ExitCode::FAILURE;
272 }
273 let prefix = match table_prefix(client, table) {
274 Ok(p) => p,
275 Err(e) => {
276 eprintln!("kevy-cli lint columns: {e}");
277 return ExitCode::FAILURE;
278 }
279 };
280 let (rows, found) = match column_pairs(client, &prefix, sample, threshold) {
281 Ok(r) => r,
282 Err(e) => {
283 eprintln!("kevy-cli lint columns: {e}");
284 return ExitCode::FAILURE;
285 }
286 };
287 println!("{table}: {rows} row(s) sampled under {prefix}");
288 if found.is_empty() {
289 println!("no two columns agree on {threshold}% or more of them");
290 return ExitCode::SUCCESS;
291 }
292 for c in &found {
293 println!(" {} and {} agree on {}% ({}/{})", c.a, c.b, c.percent(), c.same, c.compared);
294 }
295 println!(
296 "a column copied to get a second sort order is the shape lesson 6 warns about — \
297 the answer is another ORDERPATH; ask IDX.ADVISE which one"
298 );
299 ExitCode::SUCCESS
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn under(pairs: &[(&str, &[&str])]) -> BTreeMap<Vec<u8>, Vec<String>> {
307 let mut m: BTreeMap<Vec<u8>, Vec<String>> = BTreeMap::new();
308 for (name, owners) in pairs {
309 m.insert(name.as_bytes().to_vec(), owners.iter().map(|o| o.to_string()).collect());
310 }
311 m
312 }
313
314 fn row(fields: &[(&str, &str)]) -> BTreeMap<String, Vec<u8>> {
315 fields.iter().map(|(k, v)| (k.to_string(), v.as_bytes().to_vec())).collect()
316 }
317
318 #[test]
321 fn a_name_under_two_owners_is_the_multi_valued_signal() {
322 let o = tally(2, &under(&[("t1", &["m:1"]), ("t2", &["m:1", "m:2"]), ("t3", &["m:2"])]));
323 assert_eq!((o.names, o.shared), (3, 1));
324 assert_eq!(o.examples[0].0, "t2");
325 assert_eq!(o.examples[0].1, ["m:1", "m:2"]);
326 }
327
328 #[test]
331 fn disjoint_owners_leave_nothing_shared() {
332 let o = tally(2, &under(&[("x", &["a"]), ("y", &["b"])]));
333 assert_eq!(o.shared, 0);
334 }
335
336 #[test]
340 fn a_copied_column_shows_up_below_perfect_agreement() {
341 let mut rows: Vec<_> =
342 (0..9).map(|i| row(&[("a", "1"), ("b", "1"), ("c", &format!("{i}"))])).collect();
343 rows.push(row(&[("a", "1"), ("b", "2"), ("c", "9")]));
344 let found = coincidences(&rows, 90);
345 let named: Vec<&str> = found.iter().map(|c| c.a.as_str()).collect();
346 assert_eq!(found.len(), 1, "only a/b agree enough, got {named:?}");
347 assert_eq!((found[0].a.as_str(), found[0].b.as_str()), ("a", "b"));
348 assert_eq!(found[0].percent(), 90);
349 }
350
351 #[test]
354 fn a_threshold_above_the_agreement_reports_nothing() {
355 let rows = vec![row(&[("a", "1"), ("b", "1")]), row(&[("a", "1"), ("b", "2")])];
356 assert!(coincidences(&rows, 60).is_empty());
357 assert_eq!(coincidences(&rows, 50).len(), 1);
358 }
359}