1use std::{
28 collections::HashMap,
29 sync::{Arc, LazyLock, Mutex},
30 time::{Duration, Instant},
31};
32
33use async_trait::async_trait;
34use cloudillo_core::scheduler::{Task, TaskId};
35use cloudillo_types::meta_adapter::{SearchObject, SearchPart};
36use serde::{Deserialize, Serialize};
37
38use crate::{
39 extract::{TextSink, extract_fields, resolve_str},
40 prelude::*,
41 rules::{DOC_ID, IndexRules, PartRule},
42};
43
44pub const DEBOUNCE_SECS: i64 = 30;
46
47pub const OBJ_FILE: char = 'F';
49pub const OBJ_DOC: char = 'D';
51
52const MAX_CONTRIBUTIONS: usize = 100_000;
63
64pub const STORE_RTDB: &str = "RTDB";
68pub const STORE_CRDT: &str = "CRDT";
69
70const THROTTLE_SECS: u64 = 5;
82const _: () = assert!(THROTTLE_SECS < DEBOUNCE_SECS as u64);
83
84const THROTTLE_MAP_CAP: usize = 4096;
89
90type ThrottleKey = (u32, Box<str>);
92
93type ThrottleMap = HashMap<ThrottleKey, Instant>;
94
95static LAST_SCHEDULED: LazyLock<Mutex<ThrottleMap>> = LazyLock::new(|| Mutex::new(HashMap::new()));
99
100fn should_schedule(now: Instant, tn_id: TnId, file_id: &str) -> bool {
106 let mut map = match LAST_SCHEDULED.lock() {
107 Ok(g) => g,
108 Err(poisoned) => poisoned.into_inner(),
109 };
110 should_schedule_in(&mut map, now, tn_id, file_id)
111}
112
113fn should_schedule_in(map: &mut ThrottleMap, now: Instant, tn_id: TnId, file_id: &str) -> bool {
119 let key: ThrottleKey = (tn_id.0, Box::from(file_id));
120 if let Some(last) = map.get(&key)
121 && now.duration_since(*last) < Duration::from_secs(THROTTLE_SECS)
122 {
123 return false;
124 }
125 if map.len() >= THROTTLE_MAP_CAP {
126 let window = Duration::from_secs(DEBOUNCE_SECS as u64);
127 map.retain(|_, last| now.duration_since(*last) < window);
128 }
129 map.insert(key, now);
130 true
131}
132
133pub fn schedule(app: &App, tn_id: TnId, file_id: &str) {
138 if !should_schedule(Instant::now(), tn_id, file_id) {
140 return;
141 }
142 let app = app.clone();
143 let file_id: Box<str> = file_id.into();
144 tokio::spawn(async move {
145 let key = format!("search.index:{}:{}", tn_id.0, file_id);
146 let task = IndexDocumentTask { tn_id, file_id: file_id.clone() };
147 if let Err(e) = app.scheduler.task(Arc::new(task)).key(key).after(DEBOUNCE_SECS).await {
148 warn!(tn_id = %tn_id, file_id = %file_id, error = %e,
149 "Failed to schedule search index task");
150 }
151 });
152}
153
154pub async fn index_document(app: &App, tn_id: TnId, file_id: &str) -> ClResult<()> {
157 let Some(file) = app.meta_adapter.read_file(tn_id, file_id).await? else {
158 return forget(app, tn_id, file_id).await;
159 };
160 if !crate::objects::is_indexable(&file) {
166 return forget(app, tn_id, file_id).await;
167 }
168
169 let content_type = file.content_type.as_deref();
174 let store_tp = file.file_tp.as_deref();
175 let rules = match (content_type, store_tp) {
176 (Some(ct), Some(STORE_RTDB | STORE_CRDT)) => read_rules(app, tn_id, ct).await,
177 _ => None,
178 };
179 let Some(rules) = rules else {
180 return app.meta_adapter.delete_search_object(tn_id, OBJ_DOC, file_id).await;
181 };
182
183 let parts = {
190 let _permit = crate::MATERIALIZE_PERMIT
191 .acquire()
192 .await
193 .map_err(|e| Error::Internal(format!("search index permit closed: {e}")))?;
194
195 let mut docs = if store_tp == Some(STORE_CRDT) {
198 crate::crdt::export_all(app, tn_id, file_id).await?
199 } else {
200 app.rtdb_adapter.export_all(tn_id, file_id).await?
201 };
202 let owned_id: Box<str> = file_id.into();
212 app.worker
213 .run_slow(move || {
214 crate::prune::prune_docs(&rules, &mut docs, tn_id, &owned_id);
215 build_parts(&rules, &docs, tn_id, &owned_id)
216 })
217 .await
218 .map_err(|e| Error::Internal(format!("Worker pool failed extracting doc: {e}")))?
219 };
220 let fts_cl = !crate::store_text(app, tn_id).await;
223
224 app.meta_adapter
225 .replace_search_object(
226 tn_id,
227 &SearchObject {
228 obj_tp: OBJ_DOC,
229 obj_id: file_id,
230 content_type,
231 owner_tag: file.owner_tag.as_deref(),
238 visibility: file.visibility,
239 root_id: Some(file.root_id.as_deref().unwrap_or(file_id)),
243 created_at: Some(file.created_at),
244 fts_cl,
245 },
246 &parts.iter().map(BuiltPart::as_search_part).collect::<Vec<_>>(),
247 )
248 .await
249}
250
251async fn forget(app: &App, tn_id: TnId, file_id: &str) -> ClResult<()> {
257 app.meta_adapter.delete_search_object(tn_id, OBJ_DOC, file_id).await
258}
259
260async fn read_rules(app: &App, tn_id: TnId, content_type: &str) -> Option<IndexRules> {
268 let fmt = cloudillo_core::doc_format::resolve(app, tn_id, content_type)
269 .await
270 .inspect_err(|e| warn!(content_type, error = %e, "Cannot read doc format"))
271 .ok()??;
272 let search = fmt.search.as_ref()?;
273 IndexRules::parse(search)
274 .inspect_err(|e| warn!(content_type, error = %e, "Invalid search manifest"))
275 .ok()
276}
277
278#[derive(Debug)]
281struct BuiltPart {
282 part_id: String,
283 part_kind: String,
284 parent_part: Option<String>,
285 anchor_id: Option<String>,
286 title: Option<String>,
287 tags: Option<String>,
288 body: String,
289 body_chars: usize,
293}
294
295impl BuiltPart {
296 fn as_search_part(&self) -> SearchPart<'_> {
297 SearchPart {
298 part_id: &self.part_id,
299 part_kind: Some(&self.part_kind),
300 parent_part: self.parent_part.as_deref(),
301 anchor_id: self.anchor_id.as_deref(),
302 title: self.title.as_deref(),
303 body: (!self.body.is_empty()).then_some(self.body.as_str()),
304 tags: self.tags.as_deref(),
305 }
306 }
307}
308
309struct Contribution {
312 owner: String,
313 sort_key: Vec<String>,
314 anchor: Option<String>,
315 text: String,
316}
317
318fn build_parts(
328 rules: &IndexRules,
329 docs: &[(Box<str>, serde_json::Value)],
330 tn_id: TnId,
331 file_id: &str,
332) -> Vec<BuiltPart> {
333 let mut parts: Vec<BuiltPart> = Vec::new();
334 let mut index: HashMap<(&str, String), usize> = HashMap::new();
336 let mut total_used: usize = 0;
339 let mut truncated = false;
340
341 for (path, doc) in docs {
343 let Some((kind, doc_id)) = split_path(path) else { continue };
344 let Some(rule) = rules.owner_rule(kind) else { continue };
345 if parts.len() >= rules.limits.max_parts {
346 truncated = true;
347 break;
348 }
349 let mut left = rules.limits.max_total_chars.saturating_sub(total_used);
356 if left == 0 {
357 truncated = true;
358 break;
359 }
360
361 let mut title = TextSink::new(rules.limits.max_body_chars.min(1024).min(left));
362 extract_fields(doc, &rule.title, &mut title);
363 left -= title.len_chars();
364 let mut tags = TextSink::new(1024.min(left));
365 extract_fields(doc, &rule.tags, &mut tags);
366 left -= tags.len_chars();
367 let mut body = TextSink::new(rules.limits.max_body_chars.min(left));
368 extract_fields(doc, &rule.body, &mut body);
369
370 truncated |= title.truncated() || tags.truncated() || body.truncated();
371
372 index.insert((kind, doc_id.to_owned()), parts.len());
373 let body = body.into_string();
374 let body_chars = body.chars().count();
375 total_used += title.len_chars() + tags.len_chars() + body_chars;
376 parts.push(BuiltPart {
377 part_id: format!("{kind}/{doc_id}"),
386 part_kind: kind.to_owned(),
387 parent_part: rule
390 .parent
391 .as_deref()
392 .and_then(|f| resolve_str(doc, f))
393 .map(|p| format!("{kind}/{p}")),
394 anchor_id: anchor_of(rule, doc, doc_id),
397 title: (!title.is_empty()).then(|| title.into_string()),
398 tags: (!tags.is_empty()).then(|| tags.into_string()),
399 body,
400 body_chars,
401 });
402 }
403
404 let mut pending: HashMap<&str, Vec<Contribution>> = HashMap::new();
421 let mut pending_chars: usize = 0;
422 let mut pending_count: usize = 0;
423 let budget_left = rules.limits.max_total_chars.saturating_sub(total_used);
424 'collect: for (path, doc) in docs {
425 let Some((kind, doc_id)) = split_path(path) else { continue };
426 for rule in rules.parts.iter().filter(|p| p.kind == kind) {
427 let Some(attach) = &rule.attach_to else { continue };
428 let Some(owner) = resolve_str(doc, &attach.field) else { continue };
429
430 let key = (attach.kind.as_str(), owner);
435 if !index.contains_key(&key) {
436 continue;
437 }
438
439 if pending_chars >= budget_left || pending_count >= MAX_CONTRIBUTIONS {
440 truncated = true;
441 break 'collect;
442 }
443
444 let mut text = TextSink::new(rules.limits.max_body_chars);
445 extract_fields(doc, &rule.body, &mut text);
446 if text.is_empty() {
447 continue;
448 }
449 pending_chars = pending_chars.saturating_add(text.len_chars());
450 pending_count += 1;
451 let (owner_kind, owner) = key;
452 pending.entry(owner_kind).or_default().push(Contribution {
453 owner,
454 sort_key: rule.order.iter().map(|f| sort_key_of(doc, f, doc_id)).collect(),
455 anchor: anchor_of(rule, doc, doc_id),
456 text: text.into_string(),
457 });
458 }
459 }
460
461 let mut owner_kinds: Vec<&str> = pending.keys().copied().collect();
465 owner_kinds.sort_unstable();
466 for owner_kind in owner_kinds {
467 let Some(mut contributions) = pending.remove(owner_kind) else { continue };
468 contributions.sort_by(|a, b| a.sort_key.cmp(&b.sort_key));
469 for c in contributions {
470 let Some(&i) = index.get(&(owner_kind, c.owner)) else { continue };
471 let Some(part) = parts.get_mut(i) else { continue };
472 let wanted = c.text.chars().count();
473 let sep = usize::from(!part.body.is_empty());
477 let room = rules
478 .limits
479 .max_body_chars
480 .saturating_sub(part.body_chars)
481 .min(rules.limits.max_total_chars.saturating_sub(total_used))
482 .saturating_sub(sep);
483 if room == 0 {
484 truncated = true;
485 continue;
486 }
487 if sep == 1 {
488 part.body.push(' ');
489 part.body_chars += 1;
490 total_used += 1;
491 }
492 part.body.extend(c.text.chars().take(room));
493 part.body_chars += wanted.min(room);
494 total_used += wanted.min(room);
495 truncated |= wanted > room;
496 if part.anchor_id.is_none() {
499 part.anchor_id = c.anchor;
500 }
501 }
502 }
503
504 parts.retain(|p| !p.body.is_empty() || p.title.is_some() || p.tags.is_some());
506
507 if truncated {
508 warn!(tn_id = %tn_id, file_id, max_parts = rules.limits.max_parts,
509 max_body_chars = rules.limits.max_body_chars,
510 max_total_chars = rules.limits.max_total_chars,
511 total_used,
512 "Search index truncated: document exceeds manifest limits");
513 }
514 parts
515}
516
517fn anchor_of(rule: &PartRule, doc: &serde_json::Value, doc_id: &str) -> Option<String> {
520 match rule.anchor.as_deref()? {
521 DOC_ID => Some(doc_id.to_owned()),
522 field => resolve_str(doc, field),
523 }
524}
525
526fn sort_key_of(doc: &serde_json::Value, field: &str, doc_id: &str) -> String {
550 if field == DOC_ID {
551 return doc_id.to_owned();
552 }
553 let Some(raw) = resolve_str(doc, field) else { return String::new() };
554 raw.parse::<f64>().map_or(raw, |n| {
555 if n.is_nan() {
556 return "f".repeat(16);
557 }
558 let bits = n.to_bits();
559 let key = if n.is_sign_negative() { !bits } else { bits ^ (1 << 63) };
560 format!("{key:016x}")
561 })
562}
563
564pub(crate) fn split_path(path: &str) -> Option<(&str, &str)> {
567 let (doc_id, collection) = {
568 let mut it = path.rsplitn(2, '/');
569 (it.next()?, it.next()?)
570 };
571 (!collection.is_empty() && !doc_id.is_empty()).then_some((collection, doc_id))
572}
573
574#[derive(Debug, Serialize, Deserialize)]
576pub struct IndexDocumentTask {
577 pub tn_id: TnId,
578 pub file_id: Box<str>,
579}
580
581#[async_trait]
582impl Task<App> for IndexDocumentTask {
583 fn kind() -> &'static str {
584 "search.index"
585 }
586
587 fn kind_of(&self) -> &'static str {
588 Self::kind()
589 }
590
591 fn build(_id: TaskId, ctx: &str) -> ClResult<Arc<dyn Task<App>>> {
592 Ok(Arc::new(serde_json::from_str::<Self>(ctx)?))
593 }
594
595 fn serialize(&self) -> String {
596 let mut obj = serde_json::Map::with_capacity(2);
600 obj.insert("tn_id".into(), self.tn_id.0.into());
601 obj.insert("file_id".into(), self.file_id.as_ref().into());
602 serde_json::Value::Object(obj).to_string()
603 }
604
605 async fn run(&self, app: &App) -> ClResult<()> {
606 index_document(app, self.tn_id, &self.file_id).await
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 fn notillo_rules() -> IndexRules {
615 IndexRules::parse(&serde_json::json!({
616 "v": 1,
617 "parts": [
618 { "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
619 { "kind": "b", "attachTo": { "kind": "p", "field": "p" },
620 "anchor": "docId", "order": ["o"],
621 "body": [{ "field": "c", "extract": "text", "excludeKeys": ["l"] }] }
622 ]
623 }))
624 .expect("rules")
625 }
626
627 fn docs() -> Vec<(Box<str>, serde_json::Value)> {
628 vec![
629 ("p/page1".into(), serde_json::json!({ "ti": "Bevezetés", "tg": ["munka"] })),
630 ("p/page2".into(), serde_json::json!({ "ti": "Részletek", "pp": "page1" })),
631 ("b/blockB".into(), serde_json::json!({ "p": "page1", "o": 10, "c": ["második"] })),
633 ("b/blockA".into(), serde_json::json!({ "p": "page1", "o": 2, "c": ["első"] })),
634 ("b/blockC".into(), serde_json::json!({ "p": "page2", "o": 1, "c": ["külön"] })),
635 ("b/orphan".into(), serde_json::json!({ "p": "gone", "o": 1, "c": ["árva"] })),
637 ]
638 }
639
640 fn build() -> Vec<BuiltPart> {
641 build_parts(¬illo_rules(), &docs(), TnId(1), "f1~doc")
642 }
643
644 #[test]
645 fn emits_one_row_per_page_with_block_text_folded_in() {
646 let parts = build();
647 assert_eq!(parts.len(), 2, "one row per page, none per block");
648
649 let page1 = parts.iter().find(|p| p.part_id == "p/page1").expect("page1");
651 assert_eq!(page1.title.as_deref(), Some("Bevezetés"));
652 assert_eq!(page1.tags.as_deref(), Some("munka"));
653 assert_eq!(page1.part_kind, "p");
654 assert_eq!(page1.body, "első második");
656
657 let page2 = parts.iter().find(|p| p.part_id == "p/page2").expect("page2");
658 assert_eq!(page2.parent_part.as_deref(), Some("p/page1"));
659 assert_eq!(page2.body, "külön");
660 }
661
662 #[test]
663 fn anchor_points_at_the_first_contributing_block() {
664 let parts = build();
665 let page1 = parts.iter().find(|p| p.part_id == "p/page1").expect("page1");
666 assert_eq!(page1.anchor_id.as_deref(), Some("blockA"), "anchor must follow reading order");
667 }
668
669 #[test]
670 fn orphan_contributions_are_dropped() {
671 let parts = build();
672 assert!(
673 parts.iter().all(|p| !p.body.contains("árva")),
674 "a block naming a missing page must not leak into another page"
675 );
676 }
677
678 #[test]
679 fn numeric_order_sorts_numerically_not_lexicographically() {
680 let docs = vec![
681 ("p/page1".into(), serde_json::json!({ "ti": "T" })),
682 ("b/b1".into(), serde_json::json!({ "p": "page1", "o": 9, "c": ["nine"] })),
683 ("b/b2".into(), serde_json::json!({ "p": "page1", "o": 10, "c": ["ten"] })),
684 ];
685 let parts = build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc");
686 assert_eq!(parts[0].body, "nine ten");
687 }
688
689 #[test]
690 fn negative_and_fractional_order_values_sort_correctly() {
691 let docs = vec![
692 ("p/page1".into(), serde_json::json!({ "ti": "T" })),
693 ("b/b1".into(), serde_json::json!({ "p": "page1", "o": 1.5, "c": ["mid"] })),
694 ("b/b2".into(), serde_json::json!({ "p": "page1", "o": -3, "c": ["first"] })),
695 ("b/b3".into(), serde_json::json!({ "p": "page1", "o": 2, "c": ["last"] })),
696 ("b/b4".into(), serde_json::json!({ "p": "page1", "o": 0.0, "c": ["zero"] })),
697 ("b/b5".into(), serde_json::json!({ "p": "page1", "o": -0.0, "c": ["negzero"] })),
698 ];
699 let parts = build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc");
700 assert_eq!(parts[0].body, "first negzero zero mid last");
701 }
702
703 #[test]
706 fn bisected_order_values_keep_their_order() {
707 let docs = vec![
708 ("p/page1".into(), serde_json::json!({ "ti": "T" })),
709 ("b/b1".into(), serde_json::json!({ "p": "page1", "o": 1.00012, "c": ["second"] })),
710 ("b/b2".into(), serde_json::json!({ "p": "page1", "o": 1.00006, "c": ["first"] })),
711 ];
712 let parts = build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc");
713 assert_eq!(parts[0].body, "first second");
714 }
715
716 #[test]
717 fn sort_keys_of_near_identical_order_values_stay_distinct() {
718 let key = |n: f64| sort_key_of(&serde_json::json!({ "o": n }), "o", "f1~doc");
719 assert_ne!(key(1.00006), key(1.00012));
720 assert!(key(1.00006) < key(1.00012));
721 assert!(key(-3.0) < key(0.0));
722 assert!(key(0.0) < key(1.5));
723
724 let nan = sort_key_of(&serde_json::json!({ "o": "NaN" }), "o", "f1~doc");
727 assert!(nan > key(f64::MAX));
728 }
729
730 #[test]
731 fn body_is_capped_at_the_manifest_limit() {
732 let rules = IndexRules::parse(&serde_json::json!({
733 "parts": [
734 { "kind": "p", "title": ["ti"] },
735 { "kind": "b", "attachTo": { "kind": "p", "field": "p" }, "body": ["c"] }
736 ],
737 "limits": { "maxBodyChars": 10 }
738 }))
739 .expect("rules");
740 let docs = vec![
741 ("p/page1".into(), serde_json::json!({ "ti": "T" })),
742 ("b/b1".into(), serde_json::json!({ "p": "page1", "c": "0123456789abcdef" })),
743 ];
744 let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
745 assert!(parts[0].body.chars().count() <= 10, "got {:?}", parts[0].body);
746 }
747
748 #[test]
749 fn max_total_chars_bounds_the_emitting_pass_too() {
750 let rules = IndexRules::parse(&serde_json::json!({
754 "parts": [{ "kind": "p", "title": ["ti"], "body": ["c"] }],
755 "limits": { "maxParts": 100, "maxBodyChars": 20, "maxTotalChars": 30 }
756 }))
757 .expect("rules");
758 let docs: Vec<(Box<str>, serde_json::Value)> = (0..10)
759 .map(|i| {
760 (
761 format!("p/page{i}").into(),
762 serde_json::json!({ "ti": format!("T{i}"), "c": "0123456789" }),
763 )
764 })
765 .collect();
766 let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
767
768 let emitted: usize = parts
769 .iter()
770 .map(|p| {
771 p.title.as_deref().unwrap_or_default().chars().count()
772 + p.tags.as_deref().unwrap_or_default().chars().count()
773 + p.body.chars().count()
774 })
775 .sum();
776 assert!(emitted <= 30, "emitted {emitted} chars past a 30-char total budget");
777 assert!(parts.len() < 10, "the pass must stop before every page, not after it");
778 }
779
780 #[test]
791 fn attached_contributions_stop_being_collected_once_the_budget_is_spent() {
792 let rules = IndexRules::parse(&serde_json::json!({
793 "parts": [
794 { "kind": "p", "title": ["ti"] },
795 { "kind": "b", "attachTo": { "kind": "p", "field": "p" },
796 "order": ["o"], "body": ["c"] }
797 ],
798 "limits": { "maxParts": 100, "maxBodyChars": 50, "maxTotalChars": 40 }
799 }))
800 .expect("rules");
801
802 let mut docs: Vec<(Box<str>, serde_json::Value)> =
803 vec![("p/page1".into(), serde_json::json!({ "ti": "T" }))];
804 for i in (1..=200).rev() {
805 docs.push((
806 format!("b/b{i:03}").into(),
807 serde_json::json!({ "p": "page1", "o": i, "c": format!("blokk{i:03}") }),
808 ));
809 }
810
811 let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
812 assert_eq!(parts.len(), 1);
813 let emitted = parts[0].title.as_deref().unwrap_or_default().chars().count()
814 + parts[0].body.chars().count();
815 assert!(emitted <= 40, "emitted {emitted} chars past a 40-char total budget");
816
817 assert!(
818 parts[0].body.contains("blokk196"),
819 "expected the first blocks off the export, got {:?}",
820 parts[0].body
821 );
822 assert!(
823 !parts[0].body.contains("blokk001"),
824 "the whole export was collected before anything was charged: {:?}",
825 parts[0].body
826 );
827
828 let again = build_parts(&rules, &docs, TnId(1), "f1~doc");
831 assert_eq!(parts[0].body, again[0].body);
832 }
833
834 #[test]
841 fn pruning_before_build_parts_keeps_style_flags_out_of_an_assembled_body() {
842 let rules = IndexRules::parse(&serde_json::json!({
843 "v": 1,
844 "parts": [
845 { "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
846 { "kind": "b", "attachTo": { "kind": "p", "field": "p" },
847 "anchor": "docId", "order": ["o"],
848 "prune": ["$..c[0:][1:]", "$..cells[0:][0:][1:]"],
849 "body": [{ "path": "c", "extract": "text", "keys": ["c", "cells", "wt"] }] }
850 ]
851 }))
852 .expect("rules");
853
854 let mut docs: Vec<(Box<str>, serde_json::Value)> = vec![
855 ("p/page1".into(), serde_json::json!({ "ti": "Bevezetés" })),
856 (
857 "b/b1".into(),
858 serde_json::json!({ "p": "page1", "o": 1,
859 "c": ["Sima ", ["félkövér", "b"], ["dőlt", "iu"]] }),
860 ),
861 (
862 "b/b2".into(),
863 serde_json::json!({ "p": "page1", "o": 2,
864 "c": [["piros", "", { "tc": "#f00" }], " és ", ["busás", "bus"]] }),
865 ),
866 ];
867 crate::prune::prune_docs(&rules, &mut docs, TnId(1), "f1~doc");
868 let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
869
870 assert_eq!(parts.len(), 1);
871 assert_eq!(parts[0].body, "Sima félkövér dőlt piros és busás");
872 for token in parts[0].body.split_whitespace() {
873 assert!(
874 !matches!(token, "b" | "i" | "u" | "s" | "c" | "bi" | "iu" | "bus"),
875 "style flag {token:?} survived into {:?}",
876 parts[0].body
877 );
878 }
879 }
880
881 #[test]
884 fn a_manifest_without_prune_indexes_exactly_as_before() {
885 let rules = notillo_rules();
886 let untouched = build_parts(&rules, &docs(), TnId(1), "f1~doc");
887
888 let mut docs = docs();
889 crate::prune::prune_docs(&rules, &mut docs, TnId(1), "f1~doc");
890 let after = build_parts(&rules, &docs, TnId(1), "f1~doc");
891
892 let fields = |ps: &[BuiltPart]| {
893 ps.iter()
894 .map(|p| (p.part_id.clone(), p.title.clone(), p.tags.clone(), p.body.clone()))
895 .collect::<Vec<_>>()
896 };
897 assert_eq!(fields(&after), fields(&untouched));
898 }
899
900 #[test]
901 fn max_parts_stops_the_emitting_pass() {
902 let rules = IndexRules::parse(&serde_json::json!({
903 "parts": [{ "kind": "p", "title": ["ti"] }],
904 "limits": { "maxParts": 2 }
905 }))
906 .expect("rules");
907 let docs: Vec<(Box<str>, serde_json::Value)> = (0..10)
908 .map(|i| {
909 (format!("p/page{i}").into(), serde_json::json!({ "ti": format!("Page {i}") }))
910 })
911 .collect();
912 assert_eq!(build_parts(&rules, &docs, TnId(1), "f1~doc").len(), 2);
913 }
914
915 #[test]
916 fn unknown_collections_and_malformed_paths_are_ignored() {
917 let docs = vec![
918 ("p/page1".into(), serde_json::json!({ "ti": "T" })),
919 ("z/other".into(), serde_json::json!({ "ti": "Not indexed" })),
920 ("noslash".into(), serde_json::json!({ "ti": "Not indexed" })),
921 ];
922 let parts = build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc");
923 assert_eq!(parts.len(), 1);
924 assert_eq!(parts[0].part_id, "p/page1");
925 }
926
927 #[test]
928 fn two_emitting_kinds_sharing_a_doc_id_get_distinct_part_ids() {
929 let rules = IndexRules::parse(&serde_json::json!({
934 "v": 1,
935 "parts": [{ "kind": "s", "title": ["ti"] }, { "kind": "n", "title": ["ti"] }]
936 }))
937 .expect("rules");
938 let docs = vec![
939 ("s/0".into(), serde_json::json!({ "ti": "Diák" })),
940 ("n/0".into(), serde_json::json!({ "ti": "Jegyzet" })),
941 ];
942 let parts = build_parts(&rules, &docs, TnId(1), "f1~doc");
943 assert_eq!(parts.len(), 2);
944 let mut ids: Vec<&str> = parts.iter().map(|p| p.part_id.as_str()).collect();
945 ids.sort_unstable();
946 assert_eq!(ids, vec!["n/0", "s/0"]);
947 }
948
949 #[test]
950 fn textless_rows_are_dropped() {
951 let docs = vec![("p/empty".into(), serde_json::json!({ "x": 1 }))];
952 assert!(build_parts(¬illo_rules(), &docs, TnId(1), "f1~doc").is_empty());
953 }
954
955 #[test]
956 fn split_path_handles_nested_collections() {
957 assert_eq!(split_path("p/page1"), Some(("p", "page1")));
958 assert_eq!(split_path("a/b/doc"), Some(("a/b", "doc")));
959 assert_eq!(split_path("noslash"), None);
960 assert_eq!(split_path("/doc"), None);
961 assert_eq!(split_path("coll/"), None);
962 }
963 #[test]
971 fn the_scheduler_throttle_suppresses_a_burst_but_not_the_next_window() {
972 let map = &mut ThrottleMap::new();
973 let tn_id = TnId(9_001);
974 let t0 = Instant::now();
975 assert!(
976 should_schedule_in(map, t0, tn_id, "f1~burst"),
977 "the first commit must reach the scheduler"
978 );
979 assert!(
980 !should_schedule_in(map, t0, tn_id, "f1~burst"),
981 "an immediate re-commit must be suppressed"
982 );
983 let inside = t0 + Duration::from_secs(THROTTLE_SECS - 1);
984 assert!(
985 !should_schedule_in(map, inside, tn_id, "f1~burst"),
986 "still inside the throttle window"
987 );
988 assert!(
989 should_schedule_in(map, t0 + Duration::from_secs(THROTTLE_SECS), tn_id, "f1~burst"),
990 "a commit a full window later must reach the scheduler again"
991 );
992 assert!(should_schedule_in(map, t0, tn_id, "f1~other"));
994 }
995
996 #[test]
999 fn the_throttle_map_is_pruned_past_its_cap() {
1000 let map = &mut ThrottleMap::new();
1001 let tn_id = TnId(9_002);
1002 let t0 = Instant::now();
1003 for i in 0..THROTTLE_MAP_CAP {
1004 should_schedule_in(map, t0, tn_id, &format!("f1~{i}"));
1005 }
1006 let later = t0 + Duration::from_secs(DEBOUNCE_SECS as u64 + 1);
1008 should_schedule_in(map, later, tn_id, "f1~last");
1009 assert_eq!(map.len(), 1, "the prune must drop entries older than the debounce window");
1010 }
1011}
1012
1013