1use std::collections::HashMap;
85
86use serde::Deserialize;
87
88use crate::prelude::*;
89
90pub const DOC_ID: &str = "docId";
94
95pub const SUPPORTED_VERSION: u32 = 1;
101
102const MAX_PART_RULES: usize = 32;
105const MAX_FIELD_RULES: usize = 32;
106const MAX_KEY_RULES: usize = 64;
112const MAX_PRUNE_RULES: usize = 8;
121const MAX_ORDER_FIELDS: usize = 8;
126const MAX_PATH_SEGMENTS: usize = 8;
127const MAX_EXTRACT_DEPTH: usize = 32;
128const MAX_JSONPATH_LEN: usize = 256;
132pub(crate) const MAX_JSONPATH_NODES: usize = 65_536;
143
144const DEFAULT_MAX_PARTS: usize = 5000;
153const DEFAULT_MAX_BODY_CHARS: usize = 32_000;
154const DEFAULT_MAX_TOTAL_CHARS: usize = 512_000;
155const DEFAULT_EXTRACT_DEPTH: usize = 16;
156
157#[derive(Debug, Clone)]
159pub struct IndexRules {
160 pub parts: Vec<PartRule>,
161 pub limits: Limits,
162}
163
164#[derive(Debug, Clone)]
166pub struct PartRule {
167 pub kind: String,
169 pub attach_to: Option<AttachTo>,
172 pub anchor: Option<String>,
175 pub order: Vec<String>,
178 pub parent: Option<String>,
180 pub prune: Vec<String>,
189 pub title: Vec<FieldRule>,
190 pub body: Vec<FieldRule>,
191 pub tags: Vec<FieldRule>,
192}
193
194#[derive(Debug, Clone)]
196pub struct AttachTo {
197 pub kind: String,
199 pub field: String,
201}
202
203#[derive(Debug, Clone)]
210pub enum Selector {
211 Dotted(Vec<String>),
214 JsonPath(Box<jsonpath_rust::parser::model::JpQuery>),
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub enum ExtractMode {
223 #[default]
226 Text,
227 String,
231}
232
233#[derive(Debug, Clone)]
235pub struct FieldRule {
236 pub selector: Selector,
238 pub mode: ExtractMode,
240 pub keys: Vec<String>,
256 pub exclude_keys: Vec<String>,
259 pub prefix: String,
264 pub prefix_keys: HashMap<String, String>,
267 pub max_depth: usize,
268}
269
270impl FieldRule {
271 pub fn dotted(field: &str) -> Self {
274 Self {
275 selector: Selector::Dotted(split_dotted(field)),
276 mode: ExtractMode::Text,
277 keys: Vec::new(),
278 exclude_keys: Vec::new(),
279 prefix: String::new(),
280 prefix_keys: HashMap::new(),
281 max_depth: DEFAULT_EXTRACT_DEPTH,
282 }
283 }
284}
285
286fn split_dotted(field: &str) -> Vec<String> {
288 field.split('.').filter(|s| !s.is_empty()).map(ToOwned::to_owned).collect()
289}
290
291#[derive(Debug, Clone, Copy)]
294pub struct Limits {
295 pub max_parts: usize,
296 pub max_body_chars: usize,
297 pub max_total_chars: usize,
298}
299
300impl Default for Limits {
301 fn default() -> Self {
302 Self {
303 max_parts: DEFAULT_MAX_PARTS,
304 max_body_chars: DEFAULT_MAX_BODY_CHARS,
305 max_total_chars: DEFAULT_MAX_TOTAL_CHARS,
306 }
307 }
308}
309
310impl IndexRules {
311 pub fn parse(value: &serde_json::Value) -> ClResult<Self> {
313 let raw: RawRules = serde_json::from_value(value.clone())
314 .map_err(|e| Error::ValidationError(format!("invalid search manifest: {e}")))?;
315 raw.validate()
316 }
317
318 pub fn owner_rule(&self, kind: &str) -> Option<&PartRule> {
320 self.parts.iter().find(|p| p.kind == kind && p.attach_to.is_none())
321 }
322}
323
324#[derive(Debug, Clone, Default)]
341pub struct ActionSearchRules {
342 pub title: Vec<FieldRule>,
343 pub body: Vec<FieldRule>,
344 pub tags: Vec<FieldRule>,
345}
346
347impl ActionSearchRules {
348 pub fn parse(value: &serde_json::Value) -> ClResult<Self> {
350 let raw: RawActionRules = serde_json::from_value(value.clone())
351 .map_err(|e| Error::ValidationError(format!("invalid action search manifest: {e}")))?;
352 raw.validate()
353 }
354
355 pub fn is_empty(&self) -> bool {
358 self.title.is_empty() && self.body.is_empty() && self.tags.is_empty()
359 }
360}
361
362#[derive(Debug, Deserialize)]
363#[serde(rename_all = "camelCase", deny_unknown_fields)]
364struct RawActionRules {
365 #[serde(default = "default_version")]
366 v: u32,
367 #[serde(default)]
368 title: Vec<RawField>,
369 #[serde(default)]
370 body: Vec<RawField>,
371 #[serde(default)]
372 tags: Vec<RawField>,
373}
374
375impl RawActionRules {
376 fn validate(self) -> ClResult<ActionSearchRules> {
377 if self.v > SUPPORTED_VERSION {
378 return Err(Error::ValidationError(format!(
379 "action search manifest version {} is newer than supported version \
380 {SUPPORTED_VERSION}",
381 self.v
382 )));
383 }
384 let fields = |raw: Vec<RawField>, what: &str| -> ClResult<Vec<FieldRule>> {
385 if raw.len() > MAX_FIELD_RULES {
386 return Err(Error::ValidationError(format!(
387 "action search manifest has {} {what} rules, max {MAX_FIELD_RULES}",
388 raw.len()
389 )));
390 }
391 raw.into_iter().map(RawField::validate).collect()
392 };
393 let rules = ActionSearchRules {
394 title: fields(self.title, "title")?,
395 body: fields(self.body, "body")?,
396 tags: fields(self.tags, "tags")?,
397 };
398 if rules.is_empty() {
399 return Err(Error::ValidationError(
400 "action search manifest selects no fields; omit it instead".into(),
401 ));
402 }
403 Ok(rules)
404 }
405}
406
407#[derive(Debug, Deserialize)]
413#[serde(rename_all = "camelCase", deny_unknown_fields)]
414struct RawRules {
415 #[serde(default = "default_version")]
416 v: u32,
417 #[serde(default)]
418 parts: Vec<RawPart>,
419 #[serde(default)]
420 limits: Option<RawLimits>,
421}
422
423fn default_version() -> u32 {
424 SUPPORTED_VERSION
425}
426
427#[derive(Debug, Deserialize)]
428#[serde(rename_all = "camelCase", deny_unknown_fields)]
429struct RawPart {
430 kind: String,
431 #[serde(default)]
432 attach_to: Option<RawAttachTo>,
433 #[serde(default)]
434 anchor: Option<String>,
435 #[serde(default)]
436 order: Vec<String>,
437 #[serde(default)]
438 parent: Option<String>,
439 #[serde(default)]
440 prune: Vec<String>,
441 #[serde(default)]
442 title: Vec<RawField>,
443 #[serde(default)]
444 body: Vec<RawField>,
445 #[serde(default)]
446 tags: Vec<RawField>,
447}
448
449#[derive(Debug, Deserialize)]
450#[serde(rename_all = "camelCase", deny_unknown_fields)]
451struct RawAttachTo {
452 kind: String,
453 field: String,
454}
455
456#[derive(Debug)]
463pub(crate) enum RawField {
464 Path(String),
465 Full(RawFullField),
466}
467
468#[derive(Debug, Deserialize)]
469#[serde(rename_all = "camelCase", deny_unknown_fields)]
470pub(crate) struct RawFullField {
471 #[serde(alias = "field")]
474 path: String,
475 #[serde(default)]
478 extract: Option<String>,
479 #[serde(default)]
480 keys: Vec<String>,
481 #[serde(default)]
482 exclude_keys: Vec<String>,
483 #[serde(default)]
484 prefix: Option<String>,
485 #[serde(default)]
486 prefix_keys: HashMap<String, String>,
487 #[serde(default)]
488 max_depth: Option<usize>,
489}
490
491impl<'de> Deserialize<'de> for RawField {
492 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
493 use serde::de::Error as _;
494 match serde_json::Value::deserialize(de)? {
495 serde_json::Value::String(path) => Ok(Self::Path(path)),
496 other => serde_json::from_value(other).map(Self::Full).map_err(D::Error::custom),
497 }
498 }
499}
500
501#[derive(Debug, Deserialize)]
502#[serde(rename_all = "camelCase", deny_unknown_fields)]
503#[allow(clippy::struct_field_names)]
504struct RawLimits {
505 #[serde(default)]
506 max_parts: Option<usize>,
507 #[serde(default)]
508 max_body_chars: Option<usize>,
509 #[serde(default)]
510 max_total_chars: Option<usize>,
511}
512
513impl RawRules {
514 fn validate(self) -> ClResult<IndexRules> {
515 if self.v > SUPPORTED_VERSION {
516 return Err(Error::ValidationError(format!(
517 "search manifest version {} is newer than supported version {SUPPORTED_VERSION}",
518 self.v
519 )));
520 }
521 if self.parts.is_empty() {
522 return Err(Error::ValidationError("search manifest has no parts".into()));
523 }
524 if self.parts.len() > MAX_PART_RULES {
525 return Err(Error::ValidationError(format!(
526 "search manifest has {} part rules, max {MAX_PART_RULES}",
527 self.parts.len()
528 )));
529 }
530
531 let parts = self.parts.into_iter().map(RawPart::validate).collect::<ClResult<Vec<_>>>()?;
532
533 for part in &parts {
536 let Some(attach) = &part.attach_to else { continue };
537 let owner_exists = parts.iter().any(|p| p.kind == attach.kind && p.attach_to.is_none());
538 if !owner_exists {
539 return Err(Error::ValidationError(format!(
540 "part '{}' attaches to '{}', which is not an emitting part",
541 part.kind, attach.kind
542 )));
543 }
544 }
545
546 let mut emitting: Vec<&str> = parts
549 .iter()
550 .filter(|p| p.attach_to.is_none())
551 .map(|p| p.kind.as_str())
552 .collect();
553 emitting.sort_unstable();
554 if emitting.windows(2).any(|w| w[0] == w[1]) {
555 return Err(Error::ValidationError(
556 "search manifest has two emitting rules for the same kind".into(),
557 ));
558 }
559
560 let defaults = Limits::default();
561 let limits = self.limits.map_or(defaults, |l| Limits {
562 max_parts: l.max_parts.unwrap_or(defaults.max_parts).clamp(1, DEFAULT_MAX_PARTS),
563 max_body_chars: l
564 .max_body_chars
565 .unwrap_or(defaults.max_body_chars)
566 .clamp(1, DEFAULT_MAX_BODY_CHARS),
567 max_total_chars: l
568 .max_total_chars
569 .unwrap_or(defaults.max_total_chars)
570 .clamp(1, DEFAULT_MAX_TOTAL_CHARS),
571 });
572
573 Ok(IndexRules { parts, limits })
574 }
575}
576
577impl RawPart {
578 fn validate(self) -> ClResult<PartRule> {
579 if self.kind.is_empty() {
580 return Err(Error::ValidationError("part rule has an empty kind".into()));
581 }
582 if self.prune.len() > MAX_PRUNE_RULES {
583 return Err(Error::ValidationError(format!(
584 "part '{}' has {} prune patterns, max {MAX_PRUNE_RULES}",
585 self.kind,
586 self.prune.len()
587 )));
588 }
589 for pattern in &self.prune {
590 validate_prune(pattern)?;
591 }
592 if self.order.len() > MAX_ORDER_FIELDS {
593 return Err(Error::ValidationError(format!(
594 "part '{}' has {} order fields, max {MAX_ORDER_FIELDS}",
595 self.kind,
596 self.order.len()
597 )));
598 }
599 for (what, path) in self
603 .order
604 .iter()
605 .map(|p| ("order", p))
606 .chain(self.anchor.iter().map(|p| ("anchor", p)))
607 .chain(self.parent.iter().map(|p| ("parent", p)))
608 {
609 if path.is_empty() {
610 return Err(Error::ValidationError(format!(
611 "part '{}' has an empty {what} path",
612 self.kind
613 )));
614 }
615 let segments = split_dotted(path);
616 if segments.len() > MAX_PATH_SEGMENTS {
617 return Err(Error::ValidationError(format!(
618 "part '{}' {what} path '{path}' has {} segments, max {MAX_PATH_SEGMENTS}",
619 self.kind,
620 segments.len()
621 )));
622 }
623 }
624 let fields = |raw: Vec<RawField>, what: &str| -> ClResult<Vec<FieldRule>> {
625 if raw.len() > MAX_FIELD_RULES {
626 return Err(Error::ValidationError(format!(
627 "part '{}' has {} {what} rules, max {MAX_FIELD_RULES}",
628 self.kind,
629 raw.len()
630 )));
631 }
632 raw.into_iter().map(RawField::validate).collect()
633 };
634
635 Ok(PartRule {
636 attach_to: self.attach_to.map(|a| AttachTo { kind: a.kind, field: a.field }),
637 anchor: self.anchor,
638 order: self.order,
639 parent: self.parent,
640 prune: self.prune,
641 title: fields(self.title, "title")?,
642 body: fields(self.body, "body")?,
643 tags: fields(self.tags, "tags")?,
644 kind: self.kind,
645 })
646 }
647}
648
649fn validate_prune(pattern: &str) -> ClResult<()> {
651 if !pattern.starts_with('$') {
655 return Err(Error::ValidationError(format!(
656 "prune pattern '{pattern}' must be a JSONPath query starting with '$'"
657 )));
658 }
659 if pattern == "$" {
664 return Err(Error::ValidationError(
665 "prune pattern '$' would delete the whole document".into(),
666 ));
667 }
668 if pattern.len() > MAX_JSONPATH_LEN {
669 return Err(Error::ValidationError(format!(
670 "prune pattern is {} chars, max {MAX_JSONPATH_LEN}",
671 pattern.len()
672 )));
673 }
674 jsonpath_rust::parser::parse_json_path(pattern)
675 .map_err(|e| Error::ValidationError(format!("invalid prune pattern '{pattern}': {e}")))?;
676 Ok(())
677}
678
679impl RawField {
680 pub(crate) fn validate(self) -> ClResult<FieldRule> {
681 let RawFullField { path, extract, keys, exclude_keys, prefix, prefix_keys, max_depth } =
684 match self {
685 Self::Path(path) => RawFullField {
686 path,
687 extract: None,
688 keys: Vec::new(),
689 exclude_keys: Vec::new(),
690 prefix: None,
691 prefix_keys: HashMap::new(),
692 max_depth: None,
693 },
694 Self::Full(full) => full,
695 };
696
697 let mode = match extract.as_deref() {
698 None | Some("text") => ExtractMode::Text,
699 Some("string") => ExtractMode::String,
700 Some(mode) => {
701 return Err(Error::ValidationError(format!("unknown extract mode '{mode}'")));
702 }
703 };
704
705 let cap = |n: usize, what: &str| -> ClResult<()> {
706 if n > MAX_KEY_RULES {
707 return Err(Error::ValidationError(format!(
708 "field '{path}' has {n} {what} entries, max {MAX_KEY_RULES}"
709 )));
710 }
711 Ok(())
712 };
713 cap(keys.len(), "keys")?;
714 cap(exclude_keys.len(), "excludeKeys")?;
715 cap(prefix_keys.len(), "prefixKeys")?;
716
717 let selector = if path.starts_with('$') {
720 if path.len() > MAX_JSONPATH_LEN {
721 return Err(Error::ValidationError(format!(
722 "JSONPath query is {} chars, max {MAX_JSONPATH_LEN}",
723 path.len()
724 )));
725 }
726 let query = jsonpath_rust::parser::parse_json_path(&path).map_err(|e| {
730 Error::ValidationError(format!("invalid JSONPath query '{path}': {e}"))
731 })?;
732 for func in ["match(", "search("] {
744 if path.contains(func) {
745 return Err(Error::ValidationError(format!(
746 "JSONPath query '{path}' uses '{func})' — regex filter functions are \
747 not supported, because they recompile the pattern at every node of \
748 every document indexed"
749 )));
750 }
751 }
752 Selector::JsonPath(Box::new(query))
753 } else {
754 let segments = split_dotted(&path);
755 if segments.len() > MAX_PATH_SEGMENTS {
756 return Err(Error::ValidationError(format!(
757 "field path '{path}' has {} segments, max {MAX_PATH_SEGMENTS}",
758 segments.len()
759 )));
760 }
761 Selector::Dotted(segments)
762 };
763
764 Ok(FieldRule {
765 selector,
766 mode,
767 keys,
768 exclude_keys,
769 prefix: prefix.unwrap_or_default(),
770 prefix_keys,
771 max_depth: max_depth.unwrap_or(DEFAULT_EXTRACT_DEPTH).clamp(1, MAX_EXTRACT_DEPTH),
772 })
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 fn parse(json: &serde_json::Value) -> ClResult<IndexRules> {
781 IndexRules::parse(json)
782 }
783
784 fn dotted(rule: &FieldRule) -> Option<&[String]> {
786 match &rule.selector {
787 Selector::Dotted(path) => Some(path),
788 Selector::JsonPath(_) => None,
789 }
790 }
791
792 #[test]
793 fn parses_the_notillo_shape() {
794 let rules = parse(&serde_json::json!({
795 "v": 1,
796 "parts": [
797 { "kind": "p", "title": ["ti"], "tags": ["tg"], "parent": "pp" },
798 { "kind": "b", "attachTo": { "kind": "p", "field": "p" },
799 "anchor": "docId", "order": ["o"],
800 "prune": ["$..c[0:][1:]", "$..cells[0:][0:][1:]"],
801 "body": [
802 { "path": "c", "extract": "text", "keys": ["c", "cells", "wt"] },
803 { "path": "$..tg", "extract": "string", "prefix": "#" },
804 "pr.caption"
805 ] }
806 ],
807 "limits": { "maxParts": 100, "maxBodyChars": 500 }
808 }))
809 .expect("parse");
810
811 assert_eq!(rules.parts.len(), 2);
812 assert_eq!(rules.limits.max_parts, 100);
813 assert_eq!(rules.limits.max_body_chars, 500);
814
815 let page = rules.owner_rule("p").expect("emitting page rule");
816 assert_eq!(dotted(&page.title[0]), Some(&["ti".to_owned()][..]));
817 assert_eq!(page.parent.as_deref(), Some("pp"));
818
819 let block = rules.parts.iter().find(|p| p.kind == "b").expect("block rule");
820 let attach = block.attach_to.as_ref().expect("attachTo");
821 assert_eq!((attach.kind.as_str(), attach.field.as_str()), ("p", "p"));
822 assert_eq!(block.prune, ["$..c[0:][1:]", "$..cells[0:][0:][1:]"]);
823 assert_eq!(block.body[0].keys, ["c", "cells", "wt"]);
824 assert_eq!(block.body[1].mode, ExtractMode::String);
825 assert_eq!(block.body[1].prefix, "#");
826 assert_eq!(dotted(&block.body[2]), Some(&["pr".to_owned(), "caption".to_owned()][..]));
828 assert!(block.body[2].keys.is_empty());
829 assert!(block.body[2].exclude_keys.is_empty());
830 assert_eq!(block.body[2].mode, ExtractMode::Text);
831 }
832
833 #[test]
834 fn accepts_field_as_an_alias_for_path() {
835 let rules = parse(&serde_json::json!({
838 "parts": [{ "kind": "b", "body": [
839 { "field": "c", "excludeKeys": ["l"], "prefixKeys": { "tg": "#" } }
840 ] }]
841 }))
842 .expect("parse");
843 let block = rules.owner_rule("b").expect("block rule");
844 assert_eq!(dotted(&block.body[0]), Some(&["c".to_owned()][..]));
845 assert_eq!(block.body[0].exclude_keys, ["l"]);
846 assert_eq!(block.body[0].prefix_keys.get("tg").map(String::as_str), Some("#"));
847 }
848
849 #[test]
850 fn rejects_a_malformed_field_entry() {
851 let err = parse(&serde_json::json!({ "parts": [{ "kind": "p", "body": [42] }] }))
852 .expect_err("a number is not a field rule");
853 assert!(format!("{err}").contains("RawFullField"), "got {err}");
854 assert!(
855 parse(&serde_json::json!({
856 "parts": [{ "kind": "p", "body": [{ "extract": "text" }] }]
857 }))
858 .is_err(),
859 "a field rule with no path selects nothing and must be refused"
860 );
861 }
862
863 #[test]
864 fn caps_the_key_list_lengths() {
865 let many: Vec<String> = (0..100).map(|i| format!("k{i}")).collect();
866 for what in ["keys", "excludeKeys"] {
867 let err = parse(&serde_json::json!({
868 "parts": [{ "kind": "p", "body": [{ "path": "c", what: many }] }]
869 }));
870 assert!(err.is_err(), "{what} must be capped");
871 }
872 }
873
874 #[test]
875 fn compiles_a_jsonpath_field_and_rejects_a_malformed_one() {
876 let rules = parse(&serde_json::json!({
877 "parts": [{ "kind": "p", "body": [{ "field": "$.c[?@.t=='p'].text" }] }]
878 }))
879 .expect("parse");
880 let page = rules.owner_rule("p").expect("page rule");
881 assert!(dotted(&page.body[0]).is_none(), "a '$…' field must compile as JSONPath");
882
883 assert!(
884 parse(&serde_json::json!({
885 "parts": [{ "kind": "p", "body": [{ "field": "$.c[?" }] }]
886 }))
887 .is_err(),
888 "a malformed query must be refused at registration, not stored"
889 );
890 assert!(
891 parse(&serde_json::json!({
892 "parts": [{ "kind": "p", "body": [{ "field": format!("$.{}", "a".repeat(300)) }] }]
893 }))
894 .is_err(),
895 "an over-long query must be refused"
896 );
897 }
898
899 #[test]
900 fn rejects_a_prune_pattern_that_would_delete_the_whole_document() {
901 let err = parse(&serde_json::json!({
905 "parts": [{ "kind": "p", "prune": ["$"], "title": ["ti"] }]
906 }))
907 .expect_err("the bare root must be refused");
908 assert!(format!("{err}").contains("whole document"), "got {err}");
909 }
910
911 #[test]
912 fn rejects_a_prune_pattern_that_is_not_a_jsonpath_query() {
913 for pattern in ["c.0".to_owned(), "$..c[?".to_owned(), format!("$.{}", "a".repeat(300))] {
914 assert!(
915 parse(&serde_json::json!({
916 "parts": [{ "kind": "p", "prune": [pattern], "title": ["ti"] }]
917 }))
918 .is_err(),
919 "'{pattern}' must be refused at registration, not stored"
920 );
921 }
922 }
923
924 #[test]
925 fn caps_the_prune_list_length() {
926 let many: Vec<String> = (0..20).map(|i| format!("$..k{i}")).collect();
927 let err = parse(&serde_json::json!({
928 "parts": [{ "kind": "p", "prune": many, "title": ["ti"] }]
929 }))
930 .expect_err("the prune list must be capped");
931 assert!(format!("{err}").contains(&MAX_PRUNE_RULES.to_string()), "got {err}");
932 }
933
934 #[test]
935 fn caps_the_order_list_length() {
936 let many: Vec<String> = (0..=MAX_ORDER_FIELDS).map(|i| format!("k{i}")).collect();
937 let err = parse(&serde_json::json!({
938 "parts": [{ "kind": "p", "order": many, "title": ["ti"] }]
939 }))
940 .expect_err("the order list must be capped");
941 assert!(format!("{err}").contains(&MAX_ORDER_FIELDS.to_string()), "got {err}");
942 }
943
944 #[test]
945 fn caps_the_segment_count_of_order_anchor_and_parent() {
946 let deep = (0..=MAX_PATH_SEGMENTS).map(|i| format!("s{i}")).collect::<Vec<_>>().join(".");
947 for what in ["order", "anchor", "parent"] {
948 let value =
949 if what == "order" { serde_json::json!([deep]) } else { serde_json::json!(deep) };
950 let manifest = serde_json::json!({
951 "parts": [{ "kind": "p", what: value, "title": ["ti"] }]
952 });
953 let Err(err) = parse(&manifest) else {
954 panic!("an over-long {what} path must be refused");
955 };
956 assert!(format!("{err}").contains(&MAX_PATH_SEGMENTS.to_string()), "got {err}");
957 }
958 }
959
960 #[test]
961 fn accepts_doc_id_as_an_anchor() {
962 parse(&serde_json::json!({
963 "parts": [{ "kind": "p", "anchor": DOC_ID, "title": ["ti"] }]
964 }))
965 .expect("`docId` is a single segment and must stay accepted");
966 }
967
968 #[test]
969 fn rejects_a_jsonpath_regex_filter() {
970 for path in ["$..[?match(@.t,'p')]", "$..[?search(@.t,'p')]"] {
973 let err = parse(&serde_json::json!({
974 "parts": [{ "kind": "p", "title": [path] }]
975 }))
976 .expect_err("a regex filter must be refused at registration");
977 assert!(format!("{err}").contains("regex filter functions"), "got {err}");
978 }
979 parse(&serde_json::json!({
981 "parts": [{ "kind": "p", "title": ["$.blocks[*].content"] }]
982 }))
983 .expect("an ordinary JSONPath query must still be accepted");
984 }
985
986 #[test]
987 fn rejects_a_newer_manifest_version() {
988 let err = parse(&serde_json::json!({ "v": 99, "parts": [{ "kind": "p" }] }));
989 assert!(err.is_err());
990 }
991
992 #[test]
993 fn rejects_attach_to_a_non_emitting_part() {
994 let err = parse(&serde_json::json!({
995 "parts": [{ "kind": "b", "attachTo": { "kind": "p", "field": "p" } }]
996 }));
997 assert!(err.is_err(), "attaching to a part that emits no rows must fail");
998 }
999
1000 #[test]
1001 fn rejects_two_emitting_rules_for_one_kind() {
1002 let err = parse(&serde_json::json!({
1003 "parts": [{ "kind": "p", "title": ["a"] }, { "kind": "p", "title": ["b"] }]
1004 }));
1005 assert!(err.is_err());
1006 }
1007
1008 #[test]
1009 fn rejects_empty_and_unknown_shapes() {
1010 assert!(parse(&serde_json::json!({ "parts": [] })).is_err());
1011 assert!(parse(&serde_json::json!({ "parts": [{ "kind": "" }] })).is_err());
1012 assert!(
1013 parse(&serde_json::json!({
1014 "parts": [{ "kind": "p", "body": [{ "field": "c", "extract": "html" }] }]
1015 }))
1016 .is_err(),
1017 "unknown extract mode must not be silently ignored"
1018 );
1019 assert!(
1020 parse(&serde_json::json!({ "parts": [{ "kind": "p", "nope": 1 }] })).is_err(),
1021 "unknown manifest keys must be rejected, not dropped"
1022 );
1023 let err = parse(&serde_json::json!({
1026 "parts": [{ "kind": "p", "body": [{ "path": "c", "keyz": ["v"] }] }]
1027 }))
1028 .expect_err("an unknown field-rule key must be rejected");
1029 assert!(format!("{err}").contains("keyz"), "the error must name the offending key: {err}");
1030 }
1031
1032 #[test]
1033 fn clamps_absurd_limits_instead_of_failing() {
1034 let rules = parse(&serde_json::json!({
1035 "parts": [{ "kind": "p" }],
1036 "limits": { "maxParts": 99_999_999, "maxBodyChars": 0 }
1037 }))
1038 .expect("parse");
1039 assert_eq!(rules.limits.max_parts, DEFAULT_MAX_PARTS);
1040 assert_eq!(rules.limits.max_body_chars, 1);
1041 }
1042}
1043
1044