Skip to main content

leviathan/
card.rs

1//! Result cards: the compact, citable view of one record.
2//!
3//! A card carries the record's id, group, date and title, the configured
4//! display fields (capped), and a snippet around the query words taken from
5//! the searched text that is not already shown. Everything else stays in the
6//! index; `get` returns the full record.
7
8use std::collections::HashSet;
9
10use anyhow::Result;
11use serde::Serialize;
12use serde_json::{Map, Value};
13
14use crate::fields::{FieldPath, Mapping};
15use crate::text::truncate;
16
17#[derive(Debug, Clone, Copy)]
18pub struct CardOptions {
19    /// Character cap per field value and snippet.
20    pub max_chars: usize,
21}
22
23impl Default for CardOptions {
24    fn default() -> Self {
25        Self { max_chars: 300 }
26    }
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct Card {
31    pub id: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub title: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub group: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub group_name: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub date: Option<String>,
40    #[serde(skip_serializing_if = "Map::is_empty")]
41    pub fields: Map<String, Value>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub snippet: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub rank: Option<usize>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub relevance: Option<f64>,
48    /// From a different group than the one asked about.
49    #[serde(skip_serializing_if = "std::ops::Not::not")]
50    pub other_group: bool,
51}
52
53impl Card {
54    pub fn build(
55        id: &str,
56        doc: &str,
57        mapping: &Mapping,
58        opts: CardOptions,
59        highlight: &[String],
60    ) -> Result<Self> {
61        let record: Value = serde_json::from_str(doc)?;
62        let cap = opts.max_chars.max(40);
63        let title =
64            mapping.first(mapping.title_paths(), &record).map(|t| truncate(&one_line(&t), cap.min(240)));
65        let (group, group_name) = mapping.group_of(&record);
66        let date = mapping.date_of(&record).map(|d| display_date(&d));
67
68        let mut fields = Map::new();
69        let mut shown: Vec<String> = title.iter().cloned().collect();
70        for path in &mapping.display {
71            let mut seen = HashSet::new();
72            let values: Vec<String> = mapping
73                .strings(path, &record)
74                .into_iter()
75                .map(|v| one_line(&v))
76                .filter(|v| seen.insert(v.clone()))
77                .collect();
78            if values.is_empty() {
79                continue;
80            }
81            let joined = values.join("; ");
82            if title.as_deref() == Some(joined.as_str()) {
83                continue;
84            }
85            shown.push(joined.clone());
86            fields.insert(label(path), Value::String(truncate(&joined, cap)));
87        }
88
89        let snippet =
90            if highlight.is_empty() { None } else { snippet(&record, mapping, highlight, &shown, cap) };
91        Ok(Card {
92            id: id.to_string(),
93            title,
94            group,
95            group_name,
96            date,
97            fields,
98            snippet,
99            rank: None,
100            relevance: None,
101            other_group: false,
102        })
103    }
104}
105
106/// `2024-03-11T14:20:00` as `2024-03-11 14:20`; midnight as the bare date.
107fn display_date(d: &str) -> String {
108    let d = d.strip_suffix(":00").filter(|s| s.len() == 16).unwrap_or(d);
109    let d = d.strip_suffix("T00:00").unwrap_or(d);
110    d.replacen('T', " ", 1)
111}
112
113fn label(path: &FieldPath) -> String {
114    path.raw.replace("[]", "")
115}
116
117fn one_line(s: &str) -> String {
118    s.split_whitespace().collect::<Vec<_>>().join(" ")
119}
120
121fn stem(word: &str) -> &str {
122    let n = word.chars().count();
123    let keep = if n <= 4 { n } else { (n - 2).max(4) };
124    let end = word.char_indices().nth(keep).map(|(i, _)| i).unwrap_or(word.len());
125    &word[..end]
126}
127
128/// Alphanumeric runs with their starting char offset.
129fn words(s: &str) -> Vec<(usize, &str)> {
130    let mut out = Vec::new();
131    let mut start: Option<(usize, usize)> = None;
132    for (ci, (bi, c)) in s.char_indices().enumerate() {
133        if c.is_alphanumeric() {
134            start.get_or_insert((ci, bi));
135        } else if let Some((sc, sb)) = start.take() {
136            out.push((sc, &s[sb..bi]));
137        }
138    }
139    if let Some((sc, sb)) = start {
140        out.push((sc, &s[sb..]));
141    }
142    out
143}
144
145/// Sentence-sized pieces, split after `.!?;` only when whitespace follows, so
146/// versions, decimals and hostnames stay whole.
147fn sentences(s: &str) -> Vec<&str> {
148    let mut out = Vec::new();
149    let mut start = 0;
150    let mut chars = s.char_indices().peekable();
151    while let Some((i, c)) = chars.next() {
152        if matches!(c, '.' | '!' | '?' | ';') && chars.peek().is_some_and(|(_, n)| n.is_whitespace()) {
153            out.push(s[start..=i].trim());
154            start = i + 1;
155        }
156    }
157    out.push(s[start..].trim());
158    out.retain(|p| !p.is_empty());
159    out
160}
161
162/// Best sentence-sized segment of the searched text by distinct query words
163/// matched, trimmed to `cap` around the first match.
164fn snippet(
165    record: &Value,
166    mapping: &Mapping,
167    highlight: &[String],
168    shown: &[String],
169    cap: usize,
170) -> Option<String> {
171    let stems: Vec<&str> = highlight.iter().map(|w| stem(w)).collect();
172    let mut texts: Vec<String> = Vec::new();
173    if mapping.text_paths().is_empty() {
174        texts.extend(mapping.strings(&FieldPath::parse(""), record));
175    } else {
176        for p in mapping.text_paths() {
177            texts.extend(mapping.strings(p, record));
178        }
179    }
180    let mut best: Option<(usize, String, usize)> = None;
181    for text in &texts {
182        let flat = one_line(text);
183        if shown.iter().any(|s| s.contains(&flat)) {
184            continue;
185        }
186        for seg in sentences(&flat) {
187            if seg.is_empty() || shown.iter().any(|s| s.contains(seg)) {
188                continue;
189            }
190            let lower = seg.to_lowercase();
191            let mut hits = HashSet::new();
192            let mut first = None;
193            for (pos, word) in words(&lower) {
194                if let Some(k) = stems.iter().position(|s| word.starts_with(s)) {
195                    hits.insert(k);
196                    first.get_or_insert(pos);
197                }
198            }
199            if !hits.is_empty() && best.as_ref().is_none_or(|(n, _, _)| hits.len() > *n) {
200                best = Some((hits.len(), seg.to_string(), first.unwrap_or(0)));
201            }
202        }
203    }
204    let (_, seg, first) = best?;
205    if seg.chars().count() <= cap {
206        return Some(seg);
207    }
208    let lead = first.saturating_sub(40);
209    let tail: String = seg.chars().skip(lead).collect();
210    let cut = truncate(&tail, cap);
211    Some(if lead > 0 { format!("\u{2026}{cut}") } else { cut })
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::config::Config;
218
219    fn mapping() -> Mapping {
220        let cfg: Config = toml::from_str(
221            r#"
222            [fields]
223            id = "id"
224            title = "subject"
225            text = ["subject", "body", "resolution"]
226            group = "customer"
227            date = "closed"
228            display = ["status", "resolution", "tags[]"]
229            empty_values = ["n/a"]
230            "#,
231        )
232        .unwrap();
233        Mapping::new(&cfg).unwrap()
234    }
235
236    #[test]
237    fn card_shows_display_fields_and_a_fresh_snippet() {
238        let doc = r#"{"id":"9","subject":"Login fails","customer":"acme","closed":"2024-03-01T00:00:00Z",
239            "status":"closed","resolution":"n/a","tags":["sso","sso","web"],
240            "body":"User reports a blank page. After the SSO certificate rotated, login loops forever. Escalated."}"#;
241        let c = Card::build(
242            "9",
243            doc,
244            &mapping(),
245            CardOptions::default(),
246            &["certificate".into(), "loops".into()],
247        )
248        .unwrap();
249        assert_eq!(c.title.as_deref(), Some("Login fails"));
250        assert_eq!(c.date.as_deref(), Some("2024-03-01"));
251        assert_eq!(c.fields.get("tags").and_then(Value::as_str), Some("sso; web"));
252        assert!(!c.fields.contains_key("resolution"));
253        assert_eq!(c.snippet.as_deref(), Some("After the SSO certificate rotated, login loops forever."));
254    }
255
256    #[test]
257    fn no_snippet_when_the_match_is_already_shown() {
258        let doc = r#"{"id":"1","subject":"Printer jam","body":"Printer jam"}"#;
259        let c = Card::build("1", doc, &mapping(), CardOptions::default(), &["printer".into()]).unwrap();
260        assert!(c.snippet.is_none());
261    }
262}