1use std::collections::{HashMap, HashSet};
8use std::fmt::Write as _;
9use std::path::Path;
10
11use anyhow::{Result, bail};
12use serde::Serialize;
13use serde_json::Value;
14
15use crate::config::{About, Config, Fields, SourceConfig};
16use crate::source::{self, Item, Source};
17use crate::text::normalize_date;
18
19const DISTINCT_CAP: usize = 5_000;
20
21#[derive(Debug, Clone, Default, Serialize)]
22pub struct FieldStats {
23 pub path: String,
24 pub present: usize,
26 pub values: usize,
27 pub strings: usize,
28 pub numbers: usize,
29 pub bools: usize,
30 pub in_array: bool,
31 pub distinct: usize,
32 #[serde(skip_serializing_if = "std::ops::Not::not")]
33 pub distinct_capped: bool,
34 pub avg_chars: f64,
35 pub avg_words: f64,
36 pub max_chars: usize,
37 pub date_share: f64,
39 #[serde(skip)]
40 seen: HashSet<String>,
41 #[serde(skip)]
42 chars: usize,
43 #[serde(skip)]
44 words: usize,
45 #[serde(skip)]
46 dates: usize,
47 #[serde(skip)]
48 order: usize,
49}
50
51#[derive(Debug, Serialize)]
52pub struct Profile {
53 pub sampled: usize,
54 pub skipped: usize,
55 pub fields: Vec<FieldStats>,
56}
57
58impl Profile {
59 fn get(&self, path: &str) -> Option<&FieldStats> {
60 self.fields.iter().find(|f| f.path == path)
61 }
62}
63
64pub fn profile(sources: &[Source], sql: Option<&str>, limit: usize) -> Result<Profile> {
66 let mut stats: HashMap<String, FieldStats> = HashMap::new();
67 let (mut sampled, mut skipped) = (0usize, 0usize);
68 for s in sources {
69 if sampled >= limit {
70 break;
71 }
72 source::read(s, sql, &mut |item| {
73 match item {
74 Item::Record { value, .. } => {
75 let mut present = HashSet::new();
76 walk(&value, String::new(), false, &mut stats, &mut present);
77 for p in present {
78 if let Some(f) = stats.get_mut(&p) {
79 f.present += 1;
80 }
81 }
82 sampled += 1;
83 }
84 Item::Bad { .. } => skipped += 1,
85 }
86 Ok(sampled < limit)
87 })?;
88 }
89 if sampled == 0 {
90 bail!("no readable records in the sources ({skipped} unusable)");
91 }
92 let mut fields: Vec<FieldStats> = stats
93 .into_values()
94 .map(|mut f| {
95 f.distinct = f.seen.len();
96 let n = f.values.max(1) as f64;
97 f.avg_chars = (f.chars as f64 / n * 10.0).round() / 10.0;
98 f.avg_words = (f.words as f64 / n * 10.0).round() / 10.0;
99 f.date_share = (f.dates as f64 / n * 100.0).round() / 100.0;
100 f
101 })
102 .collect();
103 fields.sort_by_key(|f| f.order);
104 Ok(Profile { sampled, skipped, fields })
105}
106
107fn walk(
108 value: &Value,
109 path: String,
110 in_array: bool,
111 stats: &mut HashMap<String, FieldStats>,
112 present: &mut HashSet<String>,
113) {
114 match value {
115 Value::Object(map) => {
116 for (k, v) in map {
117 let child = if path.is_empty() { k.clone() } else { format!("{path}.{k}") };
118 walk(v, child, in_array, stats, present);
119 }
120 }
121 Value::Array(items) => {
122 let child = if path.is_empty() { "[]".to_string() } else { format!("{path}[]") };
123 for item in items {
124 walk(item, child.clone(), true, stats, present);
125 }
126 }
127 Value::Null => {}
128 scalar => {
129 let text = match scalar {
130 Value::String(s) => s.trim().to_string(),
131 other => other.to_string(),
132 };
133 if text.is_empty() {
134 return;
135 }
136 let order = stats.len();
137 let f = stats.entry(path.clone()).or_insert_with(|| FieldStats {
138 path: path.clone(),
139 order,
140 ..Default::default()
141 });
142 f.values += 1;
143 f.in_array |= in_array;
144 match scalar {
145 Value::String(_) => f.strings += 1,
146 Value::Number(_) => f.numbers += 1,
147 _ => f.bools += 1,
148 }
149 let chars = text.chars().count();
150 f.chars += chars;
151 f.max_chars = f.max_chars.max(chars);
152 f.words += text.split_whitespace().count();
153 if (matches!(scalar, Value::String(_)) || text.len() >= 9) && normalize_date(&text).is_some() {
154 f.dates += 1;
155 }
156 if f.seen.len() < DISTINCT_CAP {
157 f.seen.insert(text);
158 } else if !f.seen.contains(&text) {
159 f.distinct_capped = true;
160 }
161 present.insert(path);
162 }
163 }
164}
165
166fn name_words(name: &str) -> Vec<String> {
169 let mut out = Vec::new();
170 for part in name.split(|c: char| !c.is_alphanumeric()).filter(|p| !p.is_empty()) {
171 let mut word = String::new();
172 let mut prev_lower = false;
173 for c in part.chars() {
174 if c.is_uppercase() && prev_lower {
175 out.push(std::mem::take(&mut word));
176 }
177 prev_lower = c.is_lowercase() || c.is_numeric();
178 word.extend(c.to_lowercase());
179 }
180 out.push(word);
181 }
182 out
183}
184
185fn split_path(path: &str) -> (&str, &str) {
186 let path = path.trim_end_matches("[]");
187 match path.rsplit_once('.') {
188 Some((parent, leaf)) => (parent, leaf.trim_end_matches("[]")),
189 None => ("", path),
190 }
191}
192
193fn name_score(path: &str, hints: &[&str]) -> usize {
194 let (parent, leaf) = split_path(path);
195 let name = leaf.to_lowercase();
196 let words = name_words(leaf);
197 let parent = name_words(split_path(parent).1);
198 hints
199 .iter()
200 .position(|h| name == *h || words.iter().any(|w| w == h) || (parent.len() == 1 && parent[0] == *h))
201 .map(|i| hints.len() - i)
202 .unwrap_or(0)
203}
204
205fn base_words(leaf: &str) -> Vec<String> {
207 let mut w = name_words(leaf);
208 if w.len() > 1 && w.last().is_some_and(|l| ID_HINTS.contains(&l.as_str())) {
209 w.pop();
210 }
211 w
212}
213
214const ID_HINTS: &[&str] = &["id", "uuid", "guid", "key", "pk", "number", "ref"];
215const DATE_HINTS: &[&str] = &[
216 "date",
217 "timestamp",
218 "time",
219 "created",
220 "opened",
221 "reported",
222 "started",
223 "occurred",
224 "updated",
225 "modified",
226 "closed",
227 "resolved",
228 "completed",
229 "finished",
230 "at",
231];
232const TITLE_HINTS: &[&str] = &[
233 "title",
234 "subject",
235 "summary",
236 "headline",
237 "name",
238 "problem",
239 "issue",
240 "reason",
241 "message",
242 "description",
243];
244const GROUP_HINTS: &[&str] = &[
245 "asset",
246 "machine",
247 "equipment",
248 "device",
249 "host",
250 "hostname",
251 "server",
252 "node",
253 "service",
254 "app",
255 "application",
256 "customer",
257 "account",
258 "client",
259 "tenant",
260 "user",
261 "project",
262 "repo",
263 "repository",
264 "team",
265 "site",
266 "store",
267 "location",
268 "product",
269 "sku",
270 "vehicle",
271 "patient",
272 "component",
273];
274const FILTER_HINTS: &[&str] = &[
275 "status",
276 "state",
277 "type",
278 "kind",
279 "category",
280 "priority",
281 "severity",
282 "level",
283 "tags",
284 "tag",
285 "labels",
286 "label",
287 "stage",
288 "channel",
289 "source",
290 "region",
291 "env",
292 "environment",
293 "crew",
294 "team",
295 "shop",
296];
297
298#[derive(Debug, Serialize)]
300pub struct Proposal {
301 pub config: Config,
302 pub reasons: Vec<(String, String)>,
303 pub profile: Profile,
304}
305
306pub fn propose(profile: Profile, sources: &[Source]) -> Proposal {
307 let n = profile.sampled;
308 let share = |f: &FieldStats| f.present as f64 / n as f64;
309 let mut reasons: Vec<(String, String)> = Vec::new();
310 let mut taken: HashSet<String> = HashSet::new();
311
312 let id = profile
313 .fields
314 .iter()
315 .filter(|f| {
316 !f.in_array && f.bools == 0 && share(f) >= 0.99 && f.distinct == f.values && !f.distinct_capped
317 })
318 .filter(|f| f.avg_chars <= 64.0 && f.date_share < 0.5)
319 .max_by_key(|f| (name_score(&f.path, ID_HINTS), usize::MAX - f.path.len()))
320 .filter(|f| name_score(&f.path, ID_HINTS) > 0 || f.path.split('.').count() == 1)
321 .map(|f| {
322 reasons.push(("id".into(), format!("unique in the sample ({} of {n})", f.values)));
323 f.path.clone()
324 });
325 if let Some(p) = &id {
326 taken.insert(p.clone());
327 }
328
329 let dates: Vec<String> = {
330 let mut c: Vec<&FieldStats> = profile
331 .fields
332 .iter()
333 .filter(|f| !f.in_array && f.date_share >= 0.9 && share(f) >= 0.3 && !taken.contains(&f.path))
334 .collect();
335 c.sort_by_key(|f| std::cmp::Reverse((share(f) >= 0.95, name_score(&f.path, DATE_HINTS), f.present)));
336 c.iter().take(2).map(|f| f.path.clone()).collect()
337 };
338 if let Some(first) = dates.first() {
339 let f = profile.get(first).expect("profiled");
340 reasons.push(("date".into(), format!("{:.0}% of values parse as dates", f.date_share * 100.0)));
341 }
342 taken.extend(dates.iter().cloned());
343
344 let group = profile
345 .fields
346 .iter()
347 .filter(|f| !f.in_array && f.bools == 0 && share(f) >= 0.5 && !taken.contains(&f.path))
348 .filter(|f| f.distinct >= 2 && !f.distinct_capped && (f.distinct as f64) <= (n as f64 / 4.0).max(2.0))
349 .filter(|f| f.avg_chars <= 64.0 && f.date_share < 0.5)
350 .filter(|f| name_score(&f.path, GROUP_HINTS) > 0 || group_name_for(&profile, &f.path).is_some())
351 .max_by_key(|f| {
352 (name_score(&f.path, GROUP_HINTS), group_name_for(&profile, &f.path).is_some(), f.present)
353 })
354 .map(|f| f.path.clone());
355 let group_name = group.as_deref().and_then(|g| group_name_for(&profile, g));
356 if let Some(g) = &group {
357 let f = profile.get(g).expect("profiled");
358 reasons.push(("group".into(), format!("{} distinct values in {n} records", f.distinct)));
359 taken.insert(g.clone());
360 }
361 if let Some(gn) = &group_name {
362 taken.insert(gn.clone());
363 }
364
365 let title = profile
366 .fields
367 .iter()
368 .filter(|f| !f.in_array && f.strings > 0 && share(f) >= 0.5 && !taken.contains(&f.path))
369 .filter(|f| (8.0..=240.0).contains(&f.avg_chars) && f.avg_words >= 2.0 && f.distinct * 3 >= f.values)
370 .max_by_key(|f| {
371 (name_score(&f.path, TITLE_HINTS), f.present, (1000.0 - (f.avg_chars - 60.0).abs()) as i64)
372 })
373 .map(|f| f.path.clone());
374 if let Some(t) = &title {
375 let f = profile.get(t).expect("profiled");
376 reasons.push(("title".into(), format!("short text, avg {:.0} chars", f.avg_chars)));
377 taken.insert(t.clone());
378 }
379
380 let mut filters: Vec<&FieldStats> = profile
381 .fields
382 .iter()
383 .filter(|f| !taken.contains(&f.path) && share(f) >= 0.2)
384 .filter(|f| f.distinct >= 2 && f.distinct <= 50 && f.distinct < f.values && !f.distinct_capped)
385 .filter(|f| f.avg_chars <= 40.0)
386 .filter(|f| f.date_share < 0.5 && (f.strings > 0 || f.bools > 0))
387 .collect();
388 filters.sort_by_key(|f| std::cmp::Reverse((name_score(&f.path, FILTER_HINTS), f.present)));
389 let filters: Vec<String> = filters.iter().take(8).map(|f| f.path.clone()).collect();
390 if !filters.is_empty() {
391 reasons.push(("filters".into(), "few distinct values (2 to 50)".into()));
392 }
393 taken.extend(filters.iter().cloned());
394
395 let text: Vec<String> = profile
396 .fields
397 .iter()
398 .filter(|f| !taken.contains(&f.path) && f.strings > 0 && share(f) >= 0.02)
399 .filter(|f| f.avg_chars >= 20.0 || f.avg_words >= 3.0)
400 .map(|f| f.path.clone())
401 .collect();
402 if !text.is_empty() {
403 reasons.push(("text".into(), "free text: avg 20+ chars or 3+ words".into()));
404 }
405
406 let record = sources
407 .first()
408 .and_then(|s| Path::new(&s.label()).file_stem().map(|n| n.to_string_lossy().into_owned()))
409 .map(|stem| noun_from_stem(&stem))
410 .unwrap_or_else(|| "record".into());
411 let group_noun = group.as_deref().map(noun_from_path).unwrap_or_else(|| "group".into());
412
413 let config = Config {
414 about: About { name: None, description: None, record, group: group_noun },
415 source: SourceConfig {
416 paths: sources.iter().map(|s| s.path.display().to_string()).collect(),
417 ..Default::default()
418 },
419 fields: Fields {
420 id,
421 title: title.into_iter().collect(),
422 text,
423 group,
424 group_name,
425 date: dates,
426 display: filters.iter().take(3).cloned().collect(),
427 filters,
428 empty_values: Vec::new(),
429 },
430 rank: Default::default(),
431 };
432 Proposal { config, reasons, profile }
433}
434
435fn group_name_for(profile: &Profile, group: &str) -> Option<String> {
438 const NAME_WORDS: &[&str] = &["name", "title", "label", "display"];
439 let (parent, leaf) = split_path(group);
440 let base = base_words(leaf);
441 let id_only = base.len() == 1 && ID_HINTS.contains(&base[0].as_str());
442 profile
443 .fields
444 .iter()
445 .filter(|f| f.path != group && !f.in_array && f.strings > 0 && split_path(&f.path).0 == parent)
446 .filter_map(|f| {
447 let w = name_words(split_path(&f.path).1);
448 let rest = if id_only && !parent.is_empty() {
449 w.as_slice()
450 } else if w.len() > base.len() && w.starts_with(&base) {
451 &w[base.len()..]
452 } else {
453 return None;
454 };
455 let rank = NAME_WORDS.iter().position(|n| rest.first().is_some_and(|r| r == n))?;
456 (rest.len() <= 2).then_some((rank, f.path.clone()))
457 })
458 .min()
459 .map(|(_, p)| p)
460}
461
462fn noun_from_stem(stem: &str) -> String {
463 let words = name_words(stem).join(" ");
464 if words.chars().count() < 3
465 || ["data", "export", "dump", "records", "rows", "items", "stdin", "db", "database"]
466 .contains(&words.as_str())
467 {
468 return "record".into();
469 }
470 match words.strip_suffix("ies") {
471 Some(s) => format!("{s}y"),
472 None => words.strip_suffix('s').unwrap_or(&words).to_string(),
473 }
474}
475
476fn noun_from_path(path: &str) -> String {
477 let (parent, leaf) = split_path(path);
478 let mut words = base_words(leaf);
479 if words.len() == 1 && ID_HINTS.contains(&words[0].as_str()) && !parent.is_empty() {
480 words = base_words(split_path(parent).1);
481 }
482 if words.is_empty() { "group".into() } else { words.join(" ") }
483}
484
485fn q(s: &str) -> String {
486 toml::Value::String(s.to_string()).to_string()
487}
488
489fn list(v: &[String]) -> String {
490 format!("[{}]", v.iter().map(|s| q(s)).collect::<Vec<_>>().join(", "))
491}
492
493pub fn to_toml(p: &Proposal) -> String {
495 let c = &p.config;
496 let f = &c.fields;
497 let why = |key: &str| {
498 p.reasons.iter().find(|(k, _)| k == key).map(|(_, r)| format!(" # {r}")).unwrap_or_default()
499 };
500 let opt = |key: &str, v: &Option<String>| match v {
501 Some(v) => format!("{key} = {}{}\n", q(v), why(key)),
502 None => format!("# {key} = \"\"\n"),
503 };
504 let mut out = String::new();
505 let _ = writeln!(
506 out,
507 "# leviathan.toml, proposed by `leviathan init` from {} sampled records.\n\
508 # Review it, then run `leviathan index`. Every field is a path into a record:\n\
509 # `a.b` for nesting, `items[].name` for every element of an array.\n",
510 p.profile.sampled
511 );
512 let _ = writeln!(out, "[about]");
513 let _ = writeln!(out, "# name = \"what this dataset is\"");
514 let _ = writeln!(out, "record = {} # noun for one record, used in output", q(&c.about.record));
515 let _ = writeln!(out, "group = {} # noun for one group\n", q(&c.about.group));
516 let _ = writeln!(out, "[source]");
517 let _ = writeln!(out, "paths = {}", list(&c.source.paths));
518 let _ = writeln!(out, "# format = \"auto\" # auto | jsonl | json | csv | tsv | sqlite");
519 let _ = writeln!(out, "# sql = \"SELECT * FROM table\" # SQLite sources\n");
520 let _ = writeln!(out, "[fields]");
521 out.push_str(&opt("id", &f.id));
522 if f.id.is_none() {
523 let _ = writeln!(
524 out,
525 "# (no unique field found: records are numbered <file>:<line>, and upsert is disabled)"
526 );
527 }
528 out.push_str(&match f.title.first() {
529 Some(t) => format!("title = {}{}\n", q(t), why("title")),
530 None => "# title = \"\"\n".into(),
531 });
532 let _ = writeln!(
533 out,
534 "text = {}{}",
535 list(&f.text),
536 if f.text.is_empty() { " # empty = every string".to_string() } else { why("text") }
537 );
538 out.push_str(&opt("group", &f.group));
539 out.push_str(&opt("group_name", &f.group_name));
540 let _ = writeln!(out, "date = {}{}", list(&f.date), why("date"));
541 let _ = writeln!(out, "filters = {}{}", list(&f.filters), why("filters"));
542 let _ = writeln!(out, "display = {} # shown on every result card", list(&f.display));
543 let _ = writeln!(out, "empty_values = [] # placeholders to ignore, e.g. [\"n/a\", \"-\", \"done\"]\n");
544 let _ = writeln!(out, "[rank]");
545 let _ = writeln!(out, "title_weight = 2.0");
546 let _ = writeln!(out, "# Favor records that are filled in or in a given state:");
547 let _ = writeln!(out, "# [[rank.boost]]\n# field = \"resolution\"\n# weight = 0.15");
548 let _ = writeln!(out, "# [[rank.boost]]\n# field = \"status\"\n# equals = \"closed\"\n# weight = 0.03\n");
549 let _ = writeln!(out, "# Fields seen in the sample (path: type, present, distinct, avg chars):");
550 for s in p.profile.fields.iter().take(60) {
551 let kind = match (s.strings > 0, s.numbers > 0, s.bools > 0) {
552 (true, _, _) if s.date_share >= 0.9 => "date",
553 (true, _, _) => "text",
554 (_, true, _) => "number",
555 _ => "bool",
556 };
557 let distinct = if s.distinct_capped { format!("{}+", s.distinct) } else { s.distinct.to_string() };
558 let _ = writeln!(
559 out,
560 "# {}: {kind}, {:.0}%, {distinct} distinct, {:.0}",
561 s.path,
562 s.present as f64 / p.profile.sampled as f64 * 100.0,
563 s.avg_chars
564 );
565 }
566 if p.profile.fields.len() > 60 {
567 let _ = writeln!(out, "# ... {} more", p.profile.fields.len() - 60);
568 }
569 out
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::io::Write;
576
577 #[test]
578 fn proposes_a_sensible_mapping_for_tickets() {
579 let dir = tempfile::tempdir().unwrap();
580 let path = dir.path().join("tickets.jsonl");
581 let mut f = std::fs::File::create(&path).unwrap();
582 let statuses = ["open", "closed", "pending"];
583 for i in 0..300 {
584 let customer = i % 12;
585 writeln!(
586 f,
587 "{}",
588 serde_json::json!({
589 "ticket_id": format!("T-{i}"),
590 "subject": format!("Cannot log in to portal number {i}"),
591 "body": format!("Customer reports that after the update the login page loops back, attempt {i}."),
592 "customer": {"id": format!("C{customer}"), "name": format!("Customer {customer}")},
593 "status": statuses[i % 3],
594 "created_at": format!("2024-03-{:02}T10:00:00Z", i % 28 + 1),
595 "tags": ["web", if i % 2 == 0 { "sso" } else { "billing" }],
596 })
597 )
598 .unwrap();
599 }
600 drop(f);
601 let sources = source::discover(&[path], crate::config::Format::Auto).unwrap();
602 let p = propose(profile(&sources, None, 1000).unwrap(), &sources);
603 let fields = &p.config.fields;
604 assert_eq!(fields.id.as_deref(), Some("ticket_id"));
605 assert_eq!(fields.title, ["subject"]);
606 assert_eq!(fields.group.as_deref(), Some("customer.id"));
607 assert_eq!(fields.group_name.as_deref(), Some("customer.name"));
608 assert_eq!(fields.date, ["created_at"]);
609 assert!(fields.filters.contains(&"status".to_string()));
610 assert!(fields.filters.contains(&"tags[]".to_string()));
611 assert_eq!(fields.text, ["body"]);
612 assert_eq!(p.config.about.record, "ticket");
613 assert_eq!(p.config.about.group, "customer");
614 let toml_text = to_toml(&p);
615 let parsed: Config = toml::from_str(&toml_text).unwrap();
616 assert_eq!(parsed.fields, *fields);
617 }
618
619 #[test]
620 fn spreadsheet_headers_are_matched_by_words() {
621 assert_eq!(name_words("Ticket ID"), ["ticket", "id"]);
622 assert_eq!(name_words("customerName"), ["customer", "name"]);
623 assert_eq!(noun_from_path("Customer ID"), "customer");
624 assert_eq!(noun_from_path("asset.id"), "asset");
625
626 let dir = tempfile::tempdir().unwrap();
627 let path = dir.path().join("orders.csv");
628 let mut f = std::fs::File::create(&path).unwrap();
629 writeln!(f, "Order ID,Customer ID,Customer Name,Status,Order Date,Notes").unwrap();
630 for i in 0..200 {
631 let c = i % 9;
632 writeln!(
633 f,
634 "O{i},C{c},Customer {c},{},3/{}/2024,Package arrived late and the box was damaged {i}",
635 ["new", "shipped"][i % 2],
636 i % 28 + 1
637 )
638 .unwrap();
639 }
640 drop(f);
641 let sources = source::discover(&[path], crate::config::Format::Auto).unwrap();
642 let p = propose(profile(&sources, None, 1000).unwrap(), &sources);
643 let fields = &p.config.fields;
644 assert_eq!(fields.id.as_deref(), Some("Order ID"));
645 assert_eq!(fields.group.as_deref(), Some("Customer ID"));
646 assert_eq!(fields.group_name.as_deref(), Some("Customer Name"));
647 assert_eq!(fields.date, ["Order Date"]);
648 assert_eq!(fields.filters, ["Status"]);
649 assert_eq!((p.config.about.record.as_str(), p.config.about.group.as_str()), ("order", "customer"));
650 }
651}