1use std::fmt::Write;
6
7use crate::card::Card;
8use crate::config::plural;
9use crate::index::IngestReport;
10use crate::query::{Description, GroupMatch, Scope, SearchOutcome, Sort, Status};
11
12pub fn thousands(n: i64) -> String {
13 let digits = n.unsigned_abs().to_string();
14 let mut out = String::with_capacity(digits.len() + digits.len() / 3 + 1);
15 if n < 0 {
16 out.push('-');
17 }
18 for (i, ch) in digits.chars().enumerate() {
19 if i > 0 && (digits.len() - i).is_multiple_of(3) {
20 out.push(',');
21 }
22 out.push(ch);
23 }
24 out
25}
26
27fn count(n: i64, noun: &str) -> String {
28 format!("{} {}", thousands(n), if n == 1 { noun.to_string() } else { plural(noun) })
29}
30
31fn group_label(key: &str, name: Option<&str>) -> String {
32 match name {
33 Some(n) if n != key => format!("{key} \"{n}\""),
34 _ => key.to_string(),
35 }
36}
37
38pub fn card(out: &mut String, n: usize, c: &Card, show_group: bool, group_noun: &str) {
39 let _ = write!(out, "[{n}] {}", c.id);
40 if c.other_group {
41 let _ = write!(out, " · OTHER {}", group_noun.to_uppercase());
42 }
43 if (show_group || c.other_group)
44 && let Some(g) = &c.group
45 {
46 let _ = write!(out, " {}", group_label(g, c.group_name.as_deref()));
47 }
48 if let Some(d) = &c.date {
49 let _ = write!(out, " · {d}");
50 }
51 if let Some(r) = c.relevance {
52 let digits = if r.abs() < 1.0 { 2 } else { 1 };
53 let _ = write!(out, " · rel {r:.digits$}");
54 }
55 out.push('\n');
56 if let Some(t) = &c.title {
57 let _ = writeln!(out, " {t}");
58 }
59 let (short, long): (Vec<_>, Vec<_>) = c
60 .fields
61 .iter()
62 .filter_map(|(k, v)| v.as_str().map(|v| (k, v)))
63 .partition(|(_, v)| v.chars().count() <= 40);
64 if !short.is_empty() {
65 let line: Vec<String> = short.iter().map(|(k, v)| format!("{k}: {v}")).collect();
66 let _ = writeln!(out, " {}", line.join(" · "));
67 }
68 for (k, v) in long {
69 let _ = writeln!(out, " {k}: {v}");
70 }
71 if let Some(s) = &c.snippet {
72 let _ = writeln!(out, " match: {s}");
73 }
74}
75
76fn candidates(out: &mut String, list: &[GroupMatch], record: &str) {
77 for m in list {
78 let _ = write!(
79 out,
80 " {} · {} · {}",
81 group_label(&m.key, m.name.as_deref()),
82 count(m.records, record),
83 m.match_type
84 );
85 if let Some(s) = m.similarity {
86 let _ = write!(out, " {s:.2}");
87 }
88 out.push('\n');
89 }
90}
91
92pub fn search(o: &SearchOutcome) -> String {
93 let (record, group) = (o.about.record.as_str(), o.about.group.as_str());
94 let mut out = String::from("leviathan search");
95 match o.status {
96 Status::AmbiguousGroup | Status::UnknownGroup | Status::BadRequest => {
97 for note in &o.notes {
98 let _ = writeln!(out, " · {note}");
99 }
100 if o.notes.is_empty() {
101 out.push('\n');
102 }
103 candidates(&mut out, &o.candidates, record);
104 return out;
105 }
106 Status::Ok => {}
107 }
108 match (&o.group, o.scope) {
109 (Some(g), Scope::Group) => {
110 let _ = write!(
111 out,
112 " · {group} {} ({})",
113 group_label(&g.key, g.name.as_deref()),
114 count(g.records, record)
115 );
116 }
117 (Some(g), Scope::Others) => {
118 let _ = write!(out, " · {} other than {}", plural(group), g.key);
119 }
120 _ => {}
121 }
122 if !o.query.trim().is_empty() {
123 let _ = write!(out, " · query {:?}", o.query.trim());
124 }
125 for f in &o.filters {
126 let _ = write!(out, " · {}={}", f.field, f.value);
127 }
128 if let Some(s) = &o.since {
129 let _ = write!(out, " · since {s}");
130 }
131 if let Some(u) = &o.until {
132 let _ = write!(out, " · until {u}");
133 }
134 if o.sort == Sort::Newest || crate::text::Query::parse(&o.query).is_empty() {
135 out.push_str(" · newest first");
136 }
137 let first = if o.results.is_empty() { 0 } else { o.offset + 1 };
138 let last = o.offset + o.results.len();
139 let shown = if o.offset > 0 { format!("{first}-{last}") } else { o.results.len().to_string() };
140 let _ = writeln!(
141 out,
142 " · shown {shown} of {} · {} indexed",
143 thousands(o.total_matches),
144 count(o.corpus_records, record)
145 );
146
147 let show_group = o.scope != Scope::Group;
148 for (i, c) in o.results.iter().enumerate() {
149 card(&mut out, o.offset + i + 1, c, show_group, group);
150 }
151 if o.results.is_empty() {
152 let _ = writeln!(
153 out,
154 "no matching {} (none found, not none exist: try fewer or different words)",
155 plural(record)
156 );
157 }
158 for (i, c) in o.other_groups.iter().enumerate() {
159 card(&mut out, i + 1, c, true, group);
160 }
161 for note in &o.notes {
162 let _ = writeln!(out, "note: {note}");
163 }
164 if !o.results.is_empty() || !o.other_groups.is_empty() {
165 let more = o.total_matches as usize > last;
166 let page = if more { format!(" · --offset {last} for more") } else { String::new() };
167 let _ = writeln!(out, "next: `leviathan get <id>` for a full {record}{page}");
168 }
169 out
170}
171
172pub fn resolve(query: &str, found: &[GroupMatch], record: &str, group: &str) -> String {
173 let mut out = format!(
174 "leviathan resolve · {query:?} · {}\n",
175 count(found.len() as i64, &format!("{group} candidate"))
176 );
177 candidates(&mut out, found, record);
178 if found.is_empty() {
179 let _ = writeln!(out, "no {group} matches; try part of the name or ID");
180 }
181 out
182}
183
184pub fn ingest(r: &IngestReport) -> String {
185 if !r.built {
186 return format!(
187 "leviathan index {} is current ({} records); sources and mapping unchanged, nothing to do (use --force to rebuild)\n",
188 r.index_path.display(),
189 thousands(r.record_count)
190 );
191 }
192 let mut out = format!("leviathan index {}", r.index_path.display());
193 if r.deleted > 0 {
194 let _ = write!(out, " · deleted {}", thousands(r.deleted as i64));
195 }
196 if r.records_read > 0 || r.deleted == 0 {
197 let _ = write!(
198 out,
199 " · read {} ({} new, {} replaced)",
200 thousands(r.records_read as i64),
201 thousands(r.inserted as i64),
202 thousands(r.updated as i64)
203 );
204 }
205 let _ = writeln!(
206 out,
207 " in {:.1}s\n now {} records · {} groups · {:.1} MB source -> {:.1} MB index",
208 r.elapsed_seconds,
209 thousands(r.record_count),
210 thousands(r.group_count),
211 r.source_bytes as f64 / 1e6,
212 r.index_bytes as f64 / 1e6,
213 );
214 if r.skipped_lines > 0 {
215 let _ = writeln!(
216 out,
217 " skipped {} unusable records (--strict to fail instead):",
218 thousands(r.skipped_lines as i64)
219 );
220 for e in &r.skipped_examples {
221 let _ = writeln!(out, " {e}");
222 }
223 }
224 if let Some(f) = &r.inferred_mapping {
225 let _ = writeln!(out, " mapping inferred from the data (no leviathan.toml or field flags):");
226 let none = || "-".to_string();
227 let _ = writeln!(
228 out,
229 " id {} · title {} · group {} · date {}",
230 f.id.clone().unwrap_or_else(none),
231 f.title.first().cloned().unwrap_or_else(none),
232 f.group.clone().unwrap_or_else(none),
233 f.date.first().cloned().unwrap_or_else(none)
234 );
235 let text = if f.text.is_empty() { "every string".to_string() } else { f.text.join(", ") };
236 let _ = writeln!(out, " text {text}");
237 if !f.filters.is_empty() {
238 let _ = writeln!(out, " filters {}", f.filters.join(", "));
239 }
240 let _ = writeln!(
241 out,
242 " review it with `leviathan describe`; customize with `leviathan init` + `leviathan index --force`"
243 );
244 }
245 out
246}
247
248pub fn describe(d: &Description) -> String {
249 let (record, group) = (d.about.record.as_str(), d.about.group.as_str());
250 let mut out = format!("leviathan index {}", d.index_path.display());
251 if let Some(name) = &d.about.name {
252 let _ = write!(out, " · {name}");
253 }
254 out.push('\n');
255 if let Some(desc) = &d.about.description {
256 let _ = writeln!(out, " {desc}");
257 }
258 let _ = write!(out, " {}", count(d.record_count, record));
259 if d.fields.group.is_some() {
260 let _ = write!(out, " · {}", count(d.group_count, group));
261 }
262 if let (Some(a), Some(b)) = (&d.date_min, &d.date_max) {
263 let _ = write!(out, " · dates {} .. {}", &a[..a.len().min(10)], &b[..b.len().min(10)]);
264 }
265 let _ = writeln!(
266 out,
267 " · {:.1} MB · built {}",
268 d.index_bytes as f64 / 1e6,
269 d.built_at.as_deref().unwrap_or("?")
270 );
271 if let Some(u) = &d.updated_at {
272 let _ = writeln!(out, " updated {u}");
273 }
274
275 let f = &d.fields;
276 let _ = writeln!(out, "fields{}:", if d.mapping_inferred { " (inferred)" } else { "" });
277 let mut line = Vec::new();
278 line.push(format!("id {}", f.id.as_deref().unwrap_or("<file>:<line>")));
279 if !f.title.is_empty() {
280 line.push(format!("title {}", f.title.join(" | ")));
281 }
282 if let Some(g) = &f.group {
283 line.push(match &f.group_name {
284 Some(n) => format!("{group} {g} (name {n})"),
285 None => format!("{group} {g}"),
286 });
287 }
288 if !f.date.is_empty() {
289 line.push(format!("date {}", f.date.join(" | ")));
290 }
291 let _ = writeln!(out, " {}", line.join(" · "));
292 let _ = writeln!(
293 out,
294 " searched: {}",
295 if f.text.is_empty() { "every string".into() } else { f.text.join(", ") }
296 );
297 if !f.display.is_empty() {
298 let _ = writeln!(out, " shown on cards: {}", f.display.join(", "));
299 }
300 if !d.filters.is_empty() {
301 let _ = writeln!(out, "filters (--where field=value; repeat a field for any-of):");
302 for s in &d.filters {
303 let top: Vec<String> = s.top.iter().map(|(v, n)| format!("{v} {}", thousands(*n))).collect();
304 let more = s.distinct - s.top.len() as i64;
305 let tail = if more > 0 { format!(" (+{} more)", thousands(more)) } else { String::new() };
306 let _ = writeln!(out, " {}: {}{tail}", s.field, top.join(" · "));
307 }
308 }
309 if !d.rank.boost.is_empty() {
310 let boosts: Vec<String> = d
311 .rank
312 .boost
313 .iter()
314 .map(|b| {
315 let cond = match &b.equals {
316 Some(v) => format!("{}={v}", b.field.join("|")),
317 None => format!("{} present", b.field.join("|")),
318 };
319 format!("{cond} {:+.0}%", b.weight * 100.0)
320 })
321 .collect();
322 let _ = writeln!(out, "boosts: {}", boosts.join(" · "));
323 }
324 if !d.largest_groups.is_empty() {
325 let top: Vec<String> = d
326 .largest_groups
327 .iter()
328 .map(|g| format!("{} {}", group_label(&g.key, g.name.as_deref()), thousands(g.records)))
329 .collect();
330 let _ = writeln!(out, "largest {}: {}", plural(group), top.join(" · "));
331 }
332 let _ = writeln!(out, "try:");
333 if f.group.is_some() {
334 let _ = writeln!(out, " leviathan search -g <{group}> \"<words>\" # ranked, within one {group}");
335 let _ = writeln!(
336 out,
337 " leviathan recent -g <{group}> # newest {} for a {group}",
338 plural(record)
339 );
340 }
341 let _ = writeln!(out, " leviathan search \"<words>\" # ranked, everything");
342 if let Some(first) =
343 d.filters.first().and_then(|s| s.top.first().map(|(v, _)| (s.field.clone(), v.clone())))
344 {
345 let _ = writeln!(out, " leviathan search \"<words>\" --where {}={}", first.0, first.1);
346 }
347 if !f.date.is_empty() {
348 let _ = writeln!(out, " leviathan search \"<words>\" --since 2024-01 --until 2024-06");
349 }
350 if let Some(id) = &d.example_id {
351 let _ = writeln!(out, " leviathan get {id}");
352 }
353 if d.skipped_lines > 0 {
354 let _ = writeln!(
355 out,
356 "note: {} source records were unusable and skipped at build time",
357 thousands(d.skipped_lines)
358 );
359 }
360 out
361}
362
363#[cfg(test)]
364mod tests {
365 #[test]
366 fn thousands_separators() {
367 assert_eq!(super::thousands(0), "0");
368 assert_eq!(super::thousands(999), "999");
369 assert_eq!(super::thousands(1_000_000), "1,000,000");
370 assert_eq!(super::thousands(-12_345), "-12,345");
371 }
372}