Skip to main content

leviathan/
render.rs

1//! Compact text rendering. This is what agents read by default, so every
2//! line is spent deliberately: scope and counts in the header, one short
3//! block per record, and an explicit statement when nothing was found.
4
5use 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}