1use std::collections::HashSet;
5
6use anyhow::Result;
7use serde_json::Value;
8
9use crate::config::Config;
10use crate::text::{facet_token, group_token, normalize_date};
11
12#[derive(Debug, Clone)]
16pub struct FieldPath {
17 pub raw: String,
18 segs: Vec<String>,
19 rest: Vec<String>,
20}
21
22impl FieldPath {
23 pub fn parse(raw: &str) -> Self {
24 let segs: Vec<String> = raw
25 .split('.')
26 .map(|s| s.trim().trim_end_matches("[]").to_string())
27 .filter(|s| !s.is_empty())
28 .collect();
29 let rest = (0..segs.len()).map(|i| segs[i..].join(".")).collect();
30 Self { raw: raw.trim().to_string(), segs, rest }
31 }
32
33 pub fn values<'a>(&self, record: &'a Value) -> Vec<&'a Value> {
35 let mut out = Vec::new();
36 self.walk(record, 0, &mut out);
37 out
38 }
39
40 fn walk<'a>(&self, value: &'a Value, i: usize, out: &mut Vec<&'a Value>) {
41 match value {
42 Value::Array(items) => items.iter().for_each(|item| self.walk(item, i, out)),
43 _ if i == self.segs.len() => out.push(value),
44 Value::Object(map) => {
45 if self.segs.len() - i > 1
46 && let Some(child) = map.get(&self.rest[i])
47 {
48 return self.walk(child, self.segs.len(), out);
49 }
50 if let Some(child) = map.get(&self.segs[i]) {
51 self.walk(child, i + 1, out);
52 }
53 }
54 _ => {}
55 }
56 }
57}
58
59fn push_text(value: &Value, out: &mut Vec<String>, top: bool) {
61 match value {
62 Value::String(s) => out.push(s.clone()),
63 Value::Number(n) if top => out.push(n.to_string()),
64 Value::Bool(b) if top => out.push(b.to_string()),
65 Value::Array(items) => items.iter().for_each(|v| push_text(v, out, top)),
66 Value::Object(map) => map.values().for_each(|v| push_text(v, out, false)),
67 _ => {}
68 }
69}
70
71#[derive(Debug, Clone)]
73pub struct Mapping {
74 pub config: Config,
75 id: Option<FieldPath>,
76 title: Vec<FieldPath>,
77 text: Vec<FieldPath>,
78 group: Option<FieldPath>,
79 group_name: Option<FieldPath>,
80 date: Vec<FieldPath>,
81 pub filters: Vec<FieldPath>,
82 pub display: Vec<FieldPath>,
83 empty: HashSet<String>,
84 boosts: Vec<(Vec<FieldPath>, Option<String>, f64)>,
85}
86
87#[derive(Debug, Clone, Default)]
89pub struct Prepared {
90 pub id: Option<String>,
91 pub title: Option<String>,
92 pub body: String,
93 pub names: String,
95 pub group: Option<String>,
96 pub group_name: Option<String>,
97 pub date: Option<String>,
98 pub boost: f64,
99 pub facets: Vec<(String, String)>,
101 pub tags: String,
103}
104
105fn without_names(text: &str, names: &[&str]) -> String {
109 let mut out = text.to_string();
110 for name in names.iter().filter(|n| n.chars().count() >= 3) {
111 if out.is_ascii() && name.is_ascii() {
112 let lower = out.to_ascii_lowercase();
113 let needle = name.to_ascii_lowercase();
114 let mut kept = String::with_capacity(out.len());
115 let mut last = 0;
116 for (i, _) in lower.match_indices(&needle) {
117 kept.push_str(&out[last..i]);
118 kept.push(' ');
119 last = i + needle.len();
120 }
121 kept.push_str(&out[last..]);
122 out = kept;
123 } else {
124 out = out.replace(name, " ");
125 }
126 }
127 out
128}
129
130fn placeholder_key(s: &str) -> String {
131 s.trim().trim_end_matches(['.', '!']).trim().to_lowercase()
132}
133
134impl Mapping {
135 pub fn new(config: &Config) -> Result<Self> {
136 config.validate()?;
137 let f = &config.fields;
138 let paths = |v: &[String]| v.iter().map(|p| FieldPath::parse(p)).collect::<Vec<_>>();
139 Ok(Self {
140 config: config.clone(),
141 id: f.id.as_deref().map(FieldPath::parse),
142 title: paths(&f.title),
143 text: paths(&f.text),
144 group: f.group.as_deref().map(FieldPath::parse),
145 group_name: f.group_name.as_deref().map(FieldPath::parse),
146 date: paths(&f.date),
147 filters: paths(&f.filters),
148 display: paths(&f.display),
149 empty: f.empty_values.iter().map(|s| placeholder_key(s)).collect(),
150 boosts: config
151 .rank
152 .boost
153 .iter()
154 .map(|b| (paths(&b.field), b.equals.as_ref().map(|e| e.trim().to_lowercase()), b.weight))
155 .collect(),
156 })
157 }
158
159 pub fn has_id(&self) -> bool {
160 self.id.is_some()
161 }
162 pub fn has_group(&self) -> bool {
163 self.group.is_some()
164 }
165 pub fn has_date(&self) -> bool {
166 !self.date.is_empty()
167 }
168 pub fn title_paths(&self) -> &[FieldPath] {
169 &self.title
170 }
171 pub fn text_paths(&self) -> &[FieldPath] {
172 &self.text
173 }
174
175 pub fn is_empty_value(&self, s: &str) -> bool {
177 let key = placeholder_key(s);
178 key.is_empty() || self.empty.contains(&key)
179 }
180
181 pub fn strings(&self, path: &FieldPath, record: &Value) -> Vec<String> {
183 let mut out = Vec::new();
184 for v in path.values(record) {
185 push_text(v, &mut out, true);
186 }
187 out.retain(|s| !self.is_empty_value(s));
188 out
189 }
190
191 pub fn first(&self, paths: &[FieldPath], record: &Value) -> Option<String> {
192 paths.iter().find_map(|p| self.strings(p, record).into_iter().next()).map(|s| s.trim().to_string())
193 }
194
195 pub fn record_id(&self, record: &Value) -> Option<String> {
196 self.id.as_ref().and_then(|p| self.first(std::slice::from_ref(p), record))
197 }
198
199 pub fn date_of(&self, record: &Value) -> Option<String> {
201 self.first(&self.date, record).map(|d| normalize_date(&d).unwrap_or(d))
202 }
203
204 pub fn group_of(&self, record: &Value) -> (Option<String>, Option<String>) {
205 let one =
206 |p: &Option<FieldPath>| p.as_ref().and_then(|p| self.first(std::slice::from_ref(p), record));
207 let group = one(&self.group);
208 let name = if group.is_some() { one(&self.group_name) } else { None };
209 (group, name)
210 }
211
212 pub fn prepare(&self, record: &Value) -> Prepared {
213 let (group, group_name) = self.group_of(record);
214 let own: Vec<&str> = group.iter().chain(&group_name).map(String::as_str).collect();
215 let names = own.join("\n");
216 let title = self.first(&self.title, record).map(|t| without_names(&t, &own));
217 let mut body: Vec<String> = Vec::new();
218 if self.text.is_empty() {
219 push_text(record, &mut body, true);
220 body.retain(|s| !self.is_empty_value(s));
221 } else {
222 for p in &self.text {
223 body.extend(self.strings(p, record));
224 }
225 }
226 for b in &mut body {
227 *b = without_names(b, &own);
228 }
229
230 let date = self.date_of(record);
231
232 let mut facets: Vec<(String, String)> = Vec::new();
233 let mut seen = HashSet::new();
234 for p in &self.filters {
235 for v in self.strings(p, record) {
236 let v: String = v.trim().chars().take(200).collect();
237 if seen.insert((p.raw.clone(), v.to_lowercase())) {
238 facets.push((p.raw.clone(), v));
239 }
240 }
241 }
242
243 let mut boost = 1.0;
244 for (paths, equals, weight) in &self.boosts {
245 let hit = paths.iter().any(|p| {
246 let values = self.strings(p, record);
247 match equals {
248 Some(want) => values.iter().any(|v| v.trim().to_lowercase() == *want),
249 None => !values.is_empty(),
250 }
251 });
252 if hit {
253 boost += weight;
254 }
255 }
256
257 let mut tags: Vec<String> = Vec::with_capacity(facets.len() + 1);
258 tags.extend(group.as_deref().map(group_token));
259 tags.extend(facets.iter().map(|(f, v)| facet_token(f, v)));
260
261 Prepared {
262 id: self.record_id(record),
263 title,
264 body: body.join("\n"),
265 names,
266 group,
267 group_name,
268 date,
269 boost,
270 facets,
271 tags: tags.join(" "),
272 }
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use serde_json::json;
280
281 #[test]
282 fn paths_flatten_arrays_and_match_dotted_keys() {
283 let doc = json!({"a": {"b": [{"c": 1}, {"c": 2}]}, "x.y": "flat", "tags": ["p", "q"]});
284 let vals = |p: &str| FieldPath::parse(p).values(&doc).into_iter().cloned().collect::<Vec<_>>();
285 assert_eq!(vals("a.b[].c"), [json!(1), json!(2)]);
286 assert_eq!(vals("a.b.c"), [json!(1), json!(2)]);
287 assert_eq!(vals("x.y"), [json!("flat")]);
288 assert_eq!(vals("tags[]"), [json!("p"), json!("q")]);
289 assert!(vals("a.missing").is_empty());
290 }
291
292 #[test]
293 fn prepare_applies_placeholders_facets_and_boosts() {
294 let cfg: Config = toml::from_str(
295 r#"
296 [fields]
297 id = "id"
298 title = ["subject", "body"]
299 text = ["subject", "resolution"]
300 group = "customer.id"
301 group_name = "customer.name"
302 date = "closed"
303 filters = ["status", "tags"]
304 empty_values = ["n/a", "done"]
305 [[rank.boost]]
306 field = "resolution"
307 weight = 0.2
308 [[rank.boost]]
309 field = "status"
310 equals = "Closed"
311 weight = 0.05
312 "#,
313 )
314 .unwrap();
315 let m = Mapping::new(&cfg).unwrap();
316 let p = m.prepare(&json!({
317 "id": 7, "subject": "Login fails", "resolution": "Done.",
318 "customer": {"id": "ACME", "name": "Acme Corp"}, "closed": "2024-03-01 10:00",
319 "status": "closed", "tags": ["sso", "SSO", "web"]
320 }));
321 assert_eq!(p.id.as_deref(), Some("7"));
322 assert_eq!(p.title.as_deref(), Some("Login fails"));
323 assert_eq!(p.date.as_deref(), Some("2024-03-01T10:00:00"));
324 assert_eq!(p.group.as_deref(), Some("ACME"));
325 assert!(!p.body.contains("Done"));
326 assert_eq!(p.facets.len(), 3);
327 assert!((p.boost - 1.05).abs() < 1e-9);
328 assert_eq!(p.tags.split(' ').count(), 4);
329 }
330}