1use 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 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 #[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
106fn 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
128fn 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
145fn 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
162fn 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}