Skip to main content

gaze/
rulepack.rs

1use std::collections::{BTreeSet, HashMap};
2use std::path::PathBuf;
3
4use regex::Regex;
5use serde::Deserialize;
6use thiserror::Error;
7
8use crate::{CollisionMembership, LocaleTag, PiiClass, SafetyTier};
9
10const SUPPORTED_SCHEMA_MAJOR_MINOR: &str = "0.1.";
11
12#[derive(Debug, Clone, PartialEq)]
13pub struct Rulepack {
14    pub schema_version: String,
15    pub rulepack_id: String,
16    pub rulepack_version: String,
17    pub default_locales: Vec<LocaleTag>,
18    pub locale: Option<LocaleData>,
19    pub recognizers: Vec<RecognizerSpec>,
20}
21
22#[derive(Debug, Clone, PartialEq)]
23#[non_exhaustive]
24pub struct RecognizerSpec {
25    pub id: String,
26    pub class: PiiClass,
27    pub cooperates_with: Vec<String>,
28    pub collision: Option<CollisionMembership>,
29    pub enabled: bool,
30    pub safety_tier: SafetyTier,
31    pub locales: Vec<LocaleTag>,
32    pub matcher: RawMatch,
33    pub context: Option<ContextSpec>,
34    pub validator: Option<ValidatorSpec>,
35    pub normalizer: Option<NormalizerSpec>,
36    pub scoring: ScoringSpec,
37    pub token: TokenSpec,
38    pub source: Option<SourceSpec>,
39}
40
41#[derive(Debug, Clone, PartialEq, Deserialize)]
42#[serde(tag = "kind", deny_unknown_fields, rename_all = "snake_case")]
43#[non_exhaustive]
44pub enum RawMatch {
45    Regex {
46        #[serde(default)]
47        pattern: Option<String>,
48        #[serde(default)]
49        pattern_template: Option<String>,
50        #[serde(default)]
51        capture_groups: Option<Vec<u32>>,
52    },
53    Dictionary {
54        #[serde(default)]
55        terms: Vec<String>,
56        #[serde(default)]
57        terms_file: Option<String>,
58        #[serde(default)]
59        terms_from_context: Option<String>,
60        #[serde(default)]
61        case_sensitive: bool,
62    },
63    Ner {
64        model_ref: String,
65    },
66    AnchoredMatch {
67        cues_bucket: String,
68        boundary: String,
69        right_window_chars: u16,
70        name_shape: String,
71        cue_position: String,
72    },
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
76#[serde(deny_unknown_fields, rename_all = "snake_case")]
77#[non_exhaustive]
78pub enum AnchoredBoundary {
79    Punctuation,
80    Whitespace,
81    LineEnd,
82}
83
84/// Closed enum for the shape of token sequences `anchored_match` extracts.
85///
86/// v0.6 ships a single `PersonName` variant. Future variants
87/// (e.g. `Organization`, `Address`, `LegalEntity`) must justify
88/// why they aren't a locale-bucket lookup before being added here.
89/// Adding variants without that justification regresses the
90/// principle drawer `session-2026-04-25-no-codified-domain-concerns`
91/// (see also `lower_email_header_pattern_template` in pre-v0.4.1 history).
92#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
93#[serde(deny_unknown_fields, rename_all = "snake_case")]
94#[non_exhaustive]
95pub enum NameShape {
96    PersonName,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
100#[serde(deny_unknown_fields, rename_all = "snake_case")]
101#[non_exhaustive]
102pub enum CuePosition {
103    Before,
104    After,
105}
106
107#[derive(Debug, Clone, PartialEq)]
108#[non_exhaustive]
109pub struct ContextSpec {
110    pub hotwords: Vec<String>,
111    pub window: Option<u16>,
112    pub boost: Option<f32>,
113    pub exclusions: Vec<String>,
114}
115
116#[derive(Debug, Clone, PartialEq)]
117pub struct ValidatorSpec {
118    pub kind: String,
119}
120
121#[derive(Debug, Clone, PartialEq)]
122pub struct NormalizerSpec {
123    pub kind: String,
124}
125
126#[derive(Debug, Clone, PartialEq)]
127pub struct ScoringSpec {
128    pub base: f32,
129    pub priority: i32,
130}
131
132#[derive(Debug, Clone, PartialEq)]
133#[non_exhaustive]
134pub struct TokenSpec {
135    pub family: Option<String>,
136    pub format: Option<String>,
137}
138
139#[derive(Debug, Clone, PartialEq)]
140pub struct SourceSpec {
141    pub origin: String,
142    pub from: Option<String>,
143    pub license: Option<String>,
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Default)]
147pub struct LocaleData {
148    pub buckets: HashMap<String, LocaleBucket>,
149    pub cues: HashMap<String, LocaleCueBundle>,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct LocaleBucket {
154    pub names: Vec<String>,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
158#[non_exhaustive]
159pub struct LocaleCueBundle {
160    pub names: Vec<String>,
161    pub window_chars: Option<u16>,
162}
163
164impl LocaleCueBundle {
165    /// Builds a locale cue bundle used by mandatory-anchor resolution.
166    pub fn new(names: Vec<String>, window_chars: Option<u16>) -> Self {
167        Self {
168            names,
169            window_chars,
170        }
171    }
172}
173
174#[derive(Debug, Clone, PartialEq, Eq)]
175#[non_exhaustive]
176pub enum RulepackSource {
177    Embedded(&'static str),
178    Path(PathBuf),
179}
180
181#[derive(Debug, Error)]
182#[non_exhaustive]
183pub enum RulepackError {
184    #[error("failed to read rulepack: {0}")]
185    Io(#[source] std::io::Error),
186    #[error("failed to parse rulepack TOML: {0}")]
187    Toml(#[source] toml::de::Error),
188    #[error("unsupported rulepack schema_version {found}; supported {supported}")]
189    SchemaVersion { found: String, supported: String },
190    #[error("unknown pii class: {0}")]
191    UnknownClass(String),
192    #[error("unknown locale: {0}")]
193    UnknownLocale(String),
194    #[error("unsupported matcher kind: {0}")]
195    UnsupportedMatcher(String),
196    #[error("unsupported anchored_match field '{field}' value '{value}'")]
197    UnsupportedAnchoredMatch { field: String, value: String },
198    #[error("unsupported rulepack field '{field}' in B1; planned for {planned_version}")]
199    UnsupportedField {
200        field: String,
201        planned_version: &'static str,
202    },
203    #[error("unsupported validator kind: {kind}")]
204    UnsupportedValidator { kind: String },
205    #[error("unsupported normalizer kind: {kind}")]
206    UnsupportedNormalizer { kind: String },
207    #[error("unsupported safety_tier: {value}")]
208    UnsupportedSafetyTier { value: String },
209    #[error("unsupported rule spec variant: {variant}")]
210    UnsupportedRuleSpec { variant: String },
211    #[error("duplicate recognizer id '{id}' in rulepacks '{first_pack}' and '{second_pack}'")]
212    DuplicateId {
213        id: String,
214        first_pack: String,
215        second_pack: String,
216    },
217    #[error("regex recognizer '{id}' must define exactly one of pattern or pattern_template")]
218    RegexPatternChoice { id: String },
219    #[error("invalid regex for recognizer '{id}': {source}")]
220    RegexCompile {
221        id: String,
222        #[source]
223        source: regex::Error,
224    },
225    #[error(
226        "regex recognizer '{id}' shadows Gaze token shape sample '{shadowed_shape}' with pattern '{pattern}'"
227    )]
228    TokenShapeShadow {
229        id: String,
230        pattern: String,
231        shadowed_shape: String,
232    },
233    #[error("unknown pattern_template placeholder '{placeholder}' in recognizer '{id}'")]
234    UnknownPatternTemplatePlaceholder { id: String, placeholder: String },
235    #[error(
236        "context class_map override for dictionary '{dict}' changes {old_class:?} to {new_class:?}, but {uncovered_rule}"
237    )]
238    ClassMapOverrideClash {
239        dict: String,
240        old_class: PiiClass,
241        new_class: PiiClass,
242        uncovered_rule: String,
243    },
244    #[error(
245        "same-class recognizers '{recognizer_a}' and '{recognizer_b}' both emit {class:?} but neither declares cooperates_with"
246    )]
247    SameClassWithoutCooperation {
248        class: PiiClass,
249        recognizer_a: String,
250        recognizer_b: String,
251    },
252    #[error(
253        "recognizers {recognizer_ids:?} share class {class:?} with equivalent regex shape and overlapping locale projection {locale_overlap:?}"
254    )]
255    ConflictingLocaleProjection {
256        class: PiiClass,
257        recognizer_ids: Vec<String>,
258        locale_overlap: Vec<LocaleTag>,
259    },
260    #[error("recognizer '{recognizer_id}' has invalid collision {field} '{value}'")]
261    InvalidCollisionName {
262        recognizer_id: String,
263        field: &'static str,
264        value: String,
265    },
266    #[error(
267        "recognizers '{first_recognizer_id}' and '{second_recognizer_id}' declare collision family '{family}' variant '{variant}' with inconsistent precedence {first_precedence} and {second_precedence}"
268    )]
269    InconsistentCollisionVariant {
270        family: String,
271        variant: String,
272        first_recognizer_id: String,
273        first_precedence: u32,
274        second_recognizer_id: String,
275        second_precedence: u32,
276    },
277    #[error(
278        "collision family '{family}' variants '{first_variant}' and '{second_variant}' share ambiguous precedence {precedence}"
279    )]
280    AmbiguousFamilyPrecedence {
281        family: String,
282        first_variant: String,
283        second_variant: String,
284        precedence: u32,
285    },
286}
287
288impl Rulepack {
289    pub fn load(source: RulepackSource) -> Result<Rulepack, RulepackError> {
290        let raw = match source {
291            RulepackSource::Embedded(contents) => contents.to_string(),
292            RulepackSource::Path(path) => {
293                std::fs::read_to_string(path).map_err(RulepackError::Io)?
294            }
295        };
296        Self::parse(&raw)
297    }
298
299    pub fn parse(raw: &str) -> Result<Rulepack, RulepackError> {
300        let (raw, lint) = extract_recognizer_lint_config(raw);
301        let raw: RawRulepack = toml::from_str(&raw).map_err(RulepackError::Toml)?;
302        RawRulepackWithLint { raw, lint }.try_into()
303    }
304
305    pub fn activated_classes(&self) -> BTreeSet<PiiClass> {
306        let mut classes = BTreeSet::new();
307        for recognizer in self
308            .recognizers
309            .iter()
310            .filter(|recognizer| recognizer.enabled)
311        {
312            classes.insert(recognizer.class.clone());
313            if let Some(family_class) = recognizer
314                .collision
315                .as_ref()
316                .and_then(|collision| {
317                    collision
318                        .mandatory_anchor
319                        .as_ref()
320                        .map(|_| &collision.family)
321                })
322                .map(|family| PiiClass::Custom(format!("family:{family}")))
323            {
324                classes.insert(family_class);
325            }
326        }
327        classes
328    }
329}
330
331#[derive(Debug, Deserialize)]
332#[serde(deny_unknown_fields)]
333struct RawRulepack {
334    schema_version: String,
335    rulepack_id: String,
336    rulepack_version: String,
337    #[serde(default)]
338    default_locales: Vec<String>,
339    #[serde(default)]
340    locale: Option<RawLocaleData>,
341    #[serde(default)]
342    recognizers: Vec<RawRecognizerSpec>,
343}
344
345#[derive(Debug, Default)]
346struct RawRecognizerLintConfig {
347    strict_locale_overlap: bool,
348}
349
350#[derive(Debug)]
351struct RawRulepackWithLint {
352    raw: RawRulepack,
353    lint: RawRecognizerLintConfig,
354}
355
356#[derive(Debug, Deserialize)]
357struct RawLocaleData {
358    #[serde(default)]
359    cues: HashMap<String, RawLocaleCueBundle>,
360    #[serde(flatten)]
361    buckets: HashMap<String, RawLocaleBucket>,
362}
363
364#[derive(Debug, Deserialize)]
365#[serde(deny_unknown_fields)]
366struct RawLocaleBucket {
367    names: Vec<String>,
368}
369
370#[derive(Debug, Deserialize)]
371#[serde(deny_unknown_fields)]
372struct RawLocaleCueBundle {
373    names: Vec<String>,
374    #[serde(default)]
375    window_chars: Option<u16>,
376}
377
378#[derive(Debug, Deserialize)]
379#[serde(deny_unknown_fields)]
380struct RawRecognizerSpec {
381    id: String,
382    class: String,
383    #[serde(default)]
384    cooperates_with: Vec<String>,
385    #[serde(default)]
386    collision: Option<RawCollisionSpec>,
387    #[serde(default = "default_true")]
388    enabled: bool,
389    #[serde(default)]
390    safety_tier: Option<String>,
391    #[serde(default)]
392    locales: Vec<String>,
393    #[serde(rename = "match")]
394    matcher: RawMatch,
395    #[serde(default)]
396    context: Option<RawContextSpec>,
397    #[serde(default)]
398    validator: Option<RawValidatorSpec>,
399    #[serde(default)]
400    normalizer: Option<RawNormalizerSpec>,
401    #[serde(default)]
402    scoring: Option<RawScoringSpec>,
403    #[serde(default)]
404    token: RawTokenSpec,
405    #[serde(default)]
406    source: Option<RawSourceSpec>,
407}
408
409#[derive(Debug, Deserialize)]
410#[serde(deny_unknown_fields)]
411struct RawContextSpec {
412    #[serde(default)]
413    hotwords: Vec<String>,
414    #[serde(default)]
415    window: Option<u16>,
416    #[serde(default)]
417    boost: Option<f32>,
418    #[serde(default)]
419    exclusions: Vec<String>,
420}
421
422#[derive(Debug, Deserialize)]
423#[serde(deny_unknown_fields)]
424struct RawValidatorSpec {
425    kind: String,
426}
427
428#[derive(Debug, Deserialize)]
429#[serde(deny_unknown_fields)]
430struct RawNormalizerSpec {
431    kind: String,
432}
433
434#[derive(Debug, Deserialize)]
435#[serde(deny_unknown_fields)]
436struct RawScoringSpec {
437    #[serde(default = "default_base_score")]
438    base: f32,
439    #[serde(default)]
440    priority: i32,
441}
442
443#[derive(Debug, Clone, Deserialize)]
444#[serde(deny_unknown_fields)]
445pub(crate) struct RawCollisionSpec {
446    pub(crate) family: String,
447    pub(crate) variant: String,
448    #[serde(default = "default_collision_precedence")]
449    pub(crate) precedence: u32,
450    #[serde(default)]
451    pub(crate) mandatory_anchor: Option<String>,
452}
453
454#[derive(Debug, Default, Deserialize)]
455#[serde(deny_unknown_fields)]
456struct RawTokenSpec {
457    #[serde(default)]
458    family: Option<String>,
459    #[serde(default)]
460    format: Option<String>,
461}
462
463#[derive(Debug, Deserialize)]
464#[serde(deny_unknown_fields)]
465struct RawSourceSpec {
466    origin: String,
467    #[serde(default)]
468    from: Option<String>,
469    #[serde(default)]
470    license: Option<String>,
471}
472
473impl TryFrom<RawRulepack> for Rulepack {
474    type Error = RulepackError;
475
476    fn try_from(raw: RawRulepack) -> Result<Self, Self::Error> {
477        RawRulepackWithLint {
478            raw,
479            lint: RawRecognizerLintConfig::default(),
480        }
481        .try_into()
482    }
483}
484
485impl TryFrom<RawRulepackWithLint> for Rulepack {
486    type Error = RulepackError;
487
488    fn try_from(raw_with_lint: RawRulepackWithLint) -> Result<Self, Self::Error> {
489        let raw = raw_with_lint.raw;
490        if !raw.schema_version.starts_with(SUPPORTED_SCHEMA_MAJOR_MINOR) {
491            return Err(RulepackError::SchemaVersion {
492                found: raw.schema_version,
493                supported: "~0.1.x".to_string(),
494            });
495        }
496
497        let default_locales = parse_locales(raw.default_locales)?;
498        let mut recognizers = raw
499            .recognizers
500            .into_iter()
501            .map(|recognizer| parse_recognizer(recognizer, &default_locales))
502            .collect::<Result<Vec<_>, _>>()?;
503        validate_collision_memberships(&recognizers)?;
504        apply_collision_family_cooperation(&mut recognizers);
505        validate_rulepack_recognizers(&recognizers, &default_locales, &raw_with_lint.lint)?;
506        let locale = raw.locale.map(LocaleData::from);
507        reject_anchored_match_ellipsis_cues(&recognizers, locale.as_ref())?;
508
509        Ok(Self {
510            schema_version: raw.schema_version,
511            rulepack_id: raw.rulepack_id,
512            rulepack_version: raw.rulepack_version,
513            default_locales,
514            locale,
515            recognizers,
516        })
517    }
518}
519
520fn extract_recognizer_lint_config(raw: &str) -> (String, RawRecognizerLintConfig) {
521    let mut sanitized = String::with_capacity(raw.len());
522    let mut lint = RawRecognizerLintConfig::default();
523    let mut in_lint = false;
524
525    for line in raw.lines() {
526        let trimmed = line.trim();
527        if trimmed == "[recognizers.lint]" {
528            in_lint = true;
529            continue;
530        }
531        if in_lint && trimmed.starts_with('[') {
532            in_lint = false;
533        }
534        if in_lint {
535            if let Some((key, value)) = trimmed.split_once('=') {
536                if key.trim() == "strict_locale_overlap" {
537                    lint.strict_locale_overlap = value.trim().eq_ignore_ascii_case("true");
538                }
539            }
540            continue;
541        }
542        sanitized.push_str(line);
543        sanitized.push('\n');
544    }
545
546    (sanitized, lint)
547}
548
549impl From<RawLocaleData> for LocaleData {
550    fn from(raw: RawLocaleData) -> Self {
551        Self {
552            buckets: raw
553                .buckets
554                .into_iter()
555                .map(|(name, bucket)| {
556                    (
557                        name,
558                        LocaleBucket {
559                            names: bucket.names,
560                        },
561                    )
562                })
563                .collect(),
564            cues: raw
565                .cues
566                .into_iter()
567                .map(|(name, bundle)| {
568                    (
569                        name,
570                        LocaleCueBundle::new(bundle.names, bundle.window_chars),
571                    )
572                })
573                .collect(),
574        }
575    }
576}
577
578fn parse_recognizer(
579    raw: RawRecognizerSpec,
580    default_locales: &[LocaleTag],
581) -> Result<RecognizerSpec, RulepackError> {
582    reject_unshipped_fields(&raw)?;
583    validate_matcher(&raw)?;
584    let locales = if raw.locales.is_empty() {
585        default_locales.to_vec()
586    } else {
587        parse_locales(raw.locales)?
588    };
589    let collision = raw
590        .collision
591        .map(|collision| parse_collision_membership(&raw.id, collision))
592        .transpose()?;
593    let safety_tier = raw
594        .safety_tier
595        .as_deref()
596        .map(SafetyTier::parse)
597        .transpose()
598        .map_err(|err| RulepackError::UnsupportedSafetyTier {
599            value: err.value().to_string(),
600        })?
601        .unwrap_or_default();
602
603    Ok(RecognizerSpec {
604        id: raw.id,
605        class: parse_class(&raw.class)?,
606        cooperates_with: raw.cooperates_with,
607        collision,
608        enabled: raw.enabled,
609        safety_tier,
610        locales,
611        matcher: raw.matcher,
612        context: raw.context.map(|context| ContextSpec {
613            hotwords: context.hotwords,
614            window: context.window,
615            boost: context.boost,
616            exclusions: context.exclusions,
617        }),
618        validator: raw.validator.map(|validator| ValidatorSpec {
619            kind: validator.kind,
620        }),
621        normalizer: raw.normalizer.map(|normalizer| NormalizerSpec {
622            kind: normalizer.kind,
623        }),
624        scoring: raw.scoring.map_or_else(
625            || ScoringSpec {
626                base: default_base_score(),
627                priority: 0,
628            },
629            |scoring| ScoringSpec {
630                base: scoring.base,
631                priority: scoring.priority,
632            },
633        ),
634        token: TokenSpec {
635            family: raw.token.family,
636            format: raw.token.format,
637        },
638        source: raw.source.map(|source| SourceSpec {
639            origin: source.origin,
640            from: source.from,
641            license: source.license,
642        }),
643    })
644}
645
646pub(crate) fn parse_collision_membership(
647    recognizer_id: &str,
648    raw: RawCollisionSpec,
649) -> Result<CollisionMembership, RulepackError> {
650    validate_collision_name(recognizer_id, "family", &raw.family)?;
651    validate_collision_name(recognizer_id, "variant", &raw.variant)?;
652    Ok(CollisionMembership::new(
653        raw.family,
654        raw.variant,
655        raw.precedence,
656        raw.mandatory_anchor,
657    ))
658}
659
660fn validate_collision_name(
661    recognizer_id: &str,
662    field: &'static str,
663    value: &str,
664) -> Result<(), RulepackError> {
665    let valid = !value.is_empty()
666        && value.len() <= 64
667        && value
668            .bytes()
669            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
670    if valid {
671        Ok(())
672    } else {
673        Err(RulepackError::InvalidCollisionName {
674            recognizer_id: recognizer_id.to_string(),
675            field,
676            value: value.to_string(),
677        })
678    }
679}
680
681fn validate_collision_memberships(recognizers: &[RecognizerSpec]) -> Result<(), RulepackError> {
682    let mut variant_precedence: HashMap<(&str, &str), (&str, u32)> = HashMap::new();
683    let mut family_precedence: HashMap<(&str, u32), &str> = HashMap::new();
684
685    for recognizer in recognizers {
686        let Some(collision) = &recognizer.collision else {
687            continue;
688        };
689
690        if let Some((first_id, first_precedence)) =
691            variant_precedence.get(&(collision.family.as_str(), collision.variant.as_str()))
692        {
693            if *first_precedence != collision.precedence {
694                return Err(RulepackError::InconsistentCollisionVariant {
695                    family: collision.family.clone(),
696                    variant: collision.variant.clone(),
697                    first_recognizer_id: (*first_id).to_string(),
698                    first_precedence: *first_precedence,
699                    second_recognizer_id: recognizer.id.clone(),
700                    second_precedence: collision.precedence,
701                });
702            }
703        } else {
704            variant_precedence.insert(
705                (collision.family.as_str(), collision.variant.as_str()),
706                (recognizer.id.as_str(), collision.precedence),
707            );
708        }
709
710        if let Some(first_variant) =
711            family_precedence.get(&(collision.family.as_str(), collision.precedence))
712        {
713            if *first_variant != collision.variant {
714                return Err(RulepackError::AmbiguousFamilyPrecedence {
715                    family: collision.family.clone(),
716                    first_variant: (*first_variant).to_string(),
717                    second_variant: collision.variant.clone(),
718                    precedence: collision.precedence,
719                });
720            }
721        } else {
722            family_precedence.insert(
723                (collision.family.as_str(), collision.precedence),
724                collision.variant.as_str(),
725            );
726        }
727    }
728
729    Ok(())
730}
731
732fn apply_collision_family_cooperation(recognizers: &mut [RecognizerSpec]) {
733    let mut family_members: HashMap<String, Vec<String>> = HashMap::new();
734    for recognizer in recognizers.iter() {
735        let Some(collision) = &recognizer.collision else {
736            continue;
737        };
738        family_members
739            .entry(collision.family.clone())
740            .or_default()
741            .push(recognizer.id.clone());
742    }
743
744    for recognizer in recognizers {
745        let Some(collision) = &recognizer.collision else {
746            continue;
747        };
748        let Some(siblings) = family_members.get(&collision.family) else {
749            continue;
750        };
751        for sibling in siblings {
752            if sibling != &recognizer.id && !recognizer.cooperates_with.contains(sibling) {
753                recognizer.cooperates_with.push(sibling.clone());
754            }
755        }
756    }
757}
758
759fn validate_matcher(raw: &RawRecognizerSpec) -> Result<(), RulepackError> {
760    match &raw.matcher {
761        RawMatch::Regex {
762            pattern,
763            pattern_template,
764            ..
765        } => {
766            if pattern.is_some() == pattern_template.is_some() {
767                return Err(RulepackError::RegexPatternChoice { id: raw.id.clone() });
768            }
769            if let Some(pattern) = pattern {
770                let compiled =
771                    Regex::new(pattern).map_err(|source| RulepackError::RegexCompile {
772                        id: raw.id.clone(),
773                        source,
774                    })?;
775                crate::token_shape::reject_if_shadows_token_shape(&compiled, &raw.id).map_err(
776                    |shadow| RulepackError::TokenShapeShadow {
777                        id: shadow.recognizer_id,
778                        pattern: shadow.offending_pattern,
779                        shadowed_shape: shadow.shadowed_shape,
780                    },
781                )?;
782            }
783        }
784        RawMatch::AnchoredMatch {
785            cues_bucket,
786            boundary,
787            right_window_chars,
788            name_shape,
789            cue_position,
790            ..
791        } => {
792            if cues_bucket.trim().is_empty() {
793                return Err(RulepackError::UnsupportedAnchoredMatch {
794                    field: "cues_bucket".to_string(),
795                    value: cues_bucket.clone(),
796                });
797            }
798            if !(1..=512).contains(right_window_chars) {
799                return Err(RulepackError::UnsupportedAnchoredMatch {
800                    field: "right_window_chars".to_string(),
801                    value: right_window_chars.to_string(),
802                });
803            }
804            if !matches!(boundary.as_str(), "punctuation" | "whitespace" | "line_end") {
805                return Err(RulepackError::UnsupportedAnchoredMatch {
806                    field: "boundary".to_string(),
807                    value: boundary.clone(),
808                });
809            }
810            if name_shape != "person_name" {
811                return Err(RulepackError::UnsupportedAnchoredMatch {
812                    field: "name_shape".to_string(),
813                    value: name_shape.clone(),
814                });
815            }
816            if !matches!(cue_position.as_str(), "before" | "after") {
817                return Err(RulepackError::UnsupportedAnchoredMatch {
818                    field: "cue_position".to_string(),
819                    value: cue_position.clone(),
820                });
821            }
822        }
823        RawMatch::Dictionary { .. } | RawMatch::Ner { .. } => {}
824    }
825    Ok(())
826}
827
828fn reject_anchored_match_ellipsis_cues(
829    recognizers: &[RecognizerSpec],
830    locale: Option<&LocaleData>,
831) -> Result<(), RulepackError> {
832    let Some(locale) = locale else {
833        return Ok(());
834    };
835    for recognizer in recognizers {
836        let RawMatch::AnchoredMatch { cues_bucket, .. } = &recognizer.matcher else {
837            continue;
838        };
839        let Some(bucket) = locale.buckets.get(cues_bucket) else {
840            continue;
841        };
842        if let Some(cue) = bucket.names.iter().find(|cue| cue.contains("...")) {
843            return Err(RulepackError::UnsupportedAnchoredMatch {
844                field: format!("locale.{cues_bucket}.names"),
845                value: cue.clone(),
846            });
847        }
848    }
849    Ok(())
850}
851
852fn reject_unshipped_fields(raw: &RawRecognizerSpec) -> Result<(), RulepackError> {
853    const PLANNED_VERSION: &str = "v0.4.1";
854
855    if raw
856        .token
857        .format
858        .as_deref()
859        .is_some_and(|value| !value.is_empty())
860    {
861        return Err(RulepackError::UnsupportedField {
862            field: "token.format".to_string(),
863            planned_version: PLANNED_VERSION,
864        });
865    }
866    if let Some(context) = &raw.context {
867        if !context.hotwords.is_empty() {
868            return Err(RulepackError::UnsupportedField {
869                field: "context.hotwords".to_string(),
870                planned_version: PLANNED_VERSION,
871            });
872        }
873        if context.boost.is_some() {
874            return Err(RulepackError::UnsupportedField {
875                field: "context.boost".to_string(),
876                planned_version: PLANNED_VERSION,
877            });
878        }
879        if context.window.is_some() {
880            return Err(RulepackError::UnsupportedField {
881                field: "context.window".to_string(),
882                planned_version: PLANNED_VERSION,
883            });
884        }
885    }
886    Ok(())
887}
888
889pub fn recognizer_composition_validator(
890    recognizers: &[RecognizerSpec],
891) -> Result<(), RulepackError> {
892    for (index, first) in recognizers.iter().enumerate() {
893        for second in recognizers.iter().skip(index + 1) {
894            if first.class != second.class {
895                continue;
896            }
897            if first.cooperates_with.iter().any(|id| id == &second.id)
898                || second.cooperates_with.iter().any(|id| id == &first.id)
899            {
900                continue;
901            }
902            return Err(RulepackError::SameClassWithoutCooperation {
903                class: first.class.clone(),
904                recognizer_a: first.id.clone(),
905                recognizer_b: second.id.clone(),
906            });
907        }
908    }
909    Ok(())
910}
911
912fn validate_rulepack_recognizers(
913    recognizers: &[RecognizerSpec],
914    active_locales: &[LocaleTag],
915    lint: &RawRecognizerLintConfig,
916) -> Result<(), RulepackError> {
917    recognizer_composition_validator(recognizers)?;
918    lint_locale_projection_collisions(recognizers, active_locales, lint)?;
919    lint_global_naked_patterns(recognizers);
920    Ok(())
921}
922
923fn lint_locale_projection_collisions(
924    recognizers: &[RecognizerSpec],
925    active_locales: &[LocaleTag],
926    lint: &RawRecognizerLintConfig,
927) -> Result<(), RulepackError> {
928    for (index, first) in recognizers.iter().enumerate() {
929        if !first.enabled {
930            continue;
931        }
932        let Some(first_shape) = regex_structural_shape(&first.matcher) else {
933            continue;
934        };
935        if !is_truly_naked_numeric(&first.matcher) {
936            continue;
937        }
938        let first_projection = locale_projection(&first.locales, active_locales);
939        if first_projection.is_empty() {
940            continue;
941        }
942
943        for second in recognizers.iter().skip(index + 1) {
944            if !second.enabled || first.class != second.class {
945                continue;
946            }
947            if !is_truly_naked_numeric(&second.matcher) {
948                continue;
949            }
950            if regex_structural_shape(&second.matcher).as_ref() != Some(&first_shape) {
951                continue;
952            }
953            let second_projection = locale_projection(&second.locales, active_locales);
954            if second_projection.is_empty() {
955                continue;
956            }
957
958            let recognizer_ids = vec![first.id.clone(), second.id.clone()];
959            let locale_overlap = merged_locale_projection(&first_projection, &second_projection);
960            if lint.strict_locale_overlap {
961                return Err(RulepackError::ConflictingLocaleProjection {
962                    class: first.class.clone(),
963                    recognizer_ids,
964                    locale_overlap,
965                });
966            }
967            tracing::warn!(
968                class = %first.class.class_name(),
969                recognizer_ids = ?recognizer_ids,
970                locale_overlap = ?locale_overlap,
971                "recognizers share class with naked-shape regex and non-disjoint locale projection"
972            );
973        }
974    }
975    Ok(())
976}
977
978fn lint_global_naked_patterns(recognizers: &[RecognizerSpec]) {
979    for recognizer in recognizers {
980        if !recognizer.enabled || recognizer.locales != [LocaleTag::Global] {
981            continue;
982        }
983        let Some(shape) = regex_structural_shape(&recognizer.matcher) else {
984            continue;
985        };
986        let RawMatch::Regex {
987            pattern: Some(pattern),
988            ..
989        } = &recognizer.matcher
990        else {
991            continue;
992        };
993        if shape.minimum_match_len < 6 && !has_regex_separator(pattern) {
994            tracing::warn!(
995                recognizer_id = %recognizer.id,
996                class = %recognizer.class.class_name(),
997                minimum_match_len = shape.minimum_match_len,
998                "global recognizer uses short naked regex shape"
999            );
1000        }
1001    }
1002}
1003
1004#[derive(Debug, Clone, PartialEq, Eq)]
1005struct RegexStructuralShape {
1006    minimum_match_len: usize,
1007    character_class: RegexCharacterClass,
1008}
1009
1010#[derive(Debug, Clone, PartialEq, Eq)]
1011enum RegexCharacterClass {
1012    Digit,
1013}
1014
1015fn regex_structural_shape(matcher: &RawMatch) -> Option<RegexStructuralShape> {
1016    let RawMatch::Regex {
1017        pattern: Some(pattern),
1018        pattern_template: None,
1019        ..
1020    } = matcher
1021    else {
1022        return None;
1023    };
1024    if has_unescaped_line_anchor(pattern) {
1025        return None;
1026    }
1027    digit_quantifier_minimum(pattern).map(|minimum_match_len| RegexStructuralShape {
1028        minimum_match_len,
1029        character_class: RegexCharacterClass::Digit,
1030    })
1031}
1032
1033fn is_truly_naked_numeric(matcher: &RawMatch) -> bool {
1034    let RawMatch::Regex {
1035        pattern: Some(pattern),
1036        ..
1037    } = matcher
1038    else {
1039        return false;
1040    };
1041
1042    let mut chars = pattern.chars();
1043    while let Some(ch) = chars.next() {
1044        if ch == '\\' {
1045            chars.next();
1046            continue;
1047        }
1048        if ch.is_ascii_alphabetic() {
1049            return false;
1050        }
1051    }
1052    true
1053}
1054
1055fn has_unescaped_line_anchor(pattern: &str) -> bool {
1056    let mut escaped = false;
1057    let mut in_class = false;
1058    for ch in pattern.chars() {
1059        if escaped {
1060            escaped = false;
1061            continue;
1062        }
1063        match ch {
1064            '\\' => escaped = true,
1065            '[' => in_class = true,
1066            ']' => in_class = false,
1067            '^' | '$' if !in_class => return true,
1068            _ => {}
1069        }
1070    }
1071    false
1072}
1073
1074fn digit_quantifier_minimum(pattern: &str) -> Option<usize> {
1075    find_digit_quantifier(pattern, r"\d{")
1076        .or_else(|| find_digit_quantifier(pattern, "[0-9]{"))
1077        .or_else(|| find_digit_quantifier(pattern, "[[:digit:]]{"))
1078}
1079
1080fn find_digit_quantifier(pattern: &str, needle: &str) -> Option<usize> {
1081    let start = pattern.find(needle)? + needle.len();
1082    let rest = &pattern[start..];
1083    let digits = rest
1084        .chars()
1085        .take_while(|ch| ch.is_ascii_digit())
1086        .collect::<String>();
1087    if digits.is_empty() {
1088        return None;
1089    }
1090    digits.parse().ok()
1091}
1092
1093fn locale_projection(locales: &[LocaleTag], active_locales: &[LocaleTag]) -> Vec<LocaleTag> {
1094    let mut projection = Vec::new();
1095    for locale in locales {
1096        if *locale == LocaleTag::Global {
1097            projection.push(LocaleTag::Global);
1098        } else if active_locales.iter().any(|active| active == locale) {
1099            projection.push(locale.clone());
1100        }
1101    }
1102    projection
1103}
1104
1105fn merged_locale_projection(left: &[LocaleTag], right: &[LocaleTag]) -> Vec<LocaleTag> {
1106    let mut merged = Vec::new();
1107    for locale in left.iter().chain(right) {
1108        if !merged.iter().any(|existing| existing == locale) {
1109            merged.push(locale.clone());
1110        }
1111    }
1112    merged
1113}
1114
1115fn has_regex_separator(pattern: &str) -> bool {
1116    pattern.contains('-')
1117        || pattern.contains('/')
1118        || pattern.contains('.')
1119        || pattern.contains('+')
1120        || pattern.contains("\\s")
1121        || pattern.contains("[:space:]")
1122}
1123
1124pub fn parse_class(input: &str) -> Result<PiiClass, RulepackError> {
1125    let trimmed = input.trim();
1126    let lower = trimmed.to_ascii_lowercase();
1127    match lower.as_str() {
1128        "email" => Ok(PiiClass::Email),
1129        "name" => Ok(PiiClass::Name),
1130        "location" => Ok(PiiClass::Location),
1131        "organization" => Ok(PiiClass::Organization),
1132        custom if custom.starts_with("custom:") => {
1133            let name = trimmed
1134                .split_once(':')
1135                .map(|(_, name)| name)
1136                .unwrap_or_default();
1137            if name.trim().is_empty() {
1138                return Err(RulepackError::UnknownClass(input.to_string()));
1139            }
1140            if name.starts_with("family:") {
1141                Ok(PiiClass::Custom(name.to_string()))
1142            } else {
1143                Ok(PiiClass::custom(name))
1144            }
1145        }
1146        _ => Err(RulepackError::UnknownClass(input.to_string())),
1147    }
1148}
1149
1150fn parse_locales(locales: Vec<String>) -> Result<Vec<LocaleTag>, RulepackError> {
1151    locales
1152        .into_iter()
1153        .map(|locale| {
1154            LocaleTag::parse(&locale).map_err(|_| RulepackError::UnknownLocale(locale.clone()))
1155        })
1156        .collect()
1157}
1158
1159fn default_true() -> bool {
1160    true
1161}
1162
1163fn default_base_score() -> f32 {
1164    0.70
1165}
1166
1167fn default_collision_precedence() -> u32 {
1168    100
1169}
1170
1171#[cfg(test)]
1172mod tests {
1173    use super::*;
1174
1175    const CORE: &str = r#"
1176schema_version = "0.1.0"
1177rulepack_id = "gaze-core"
1178rulepack_version = "0.4.0"
1179default_locales = ["global"]
1180
1181[locale.email_headers]
1182names = ["From", "To", "Cc", "Bcc", "Reply-To", "Sender"]
1183
1184[[recognizers]]
1185id = "email.global"
1186class = "Email"
1187enabled = true
1188locales = ["global"]
1189
1190[recognizers.match]
1191kind = "regex"
1192pattern = '''(?i)(?-u:\b)[a-z0-9_][a-z0-9._%+\-]*@(?:(?:[a-z0-9\-]+\.)*example\.invalid|test\.local|(?:[a-z0-9](?:[a-z0-9\-]*[a-z0-9])?\.)+(?:[a-z]{2,3}|[a-su-z][a-z]{3}|t[a-df-z][a-z]{2}|te[a-rt-z][a-z]|tes[a-su-z]|[a-z]{5,6}|[a-hj-z][a-z]{6}|i[a-mo-z][a-z]{5}|in[a-uw-z][a-z]{4}|inv[b-z][a-z]{3}|inva[a-km-z][a-z]{2}|inval[a-hj-z][a-z]|invali[a-ce-z]|[a-z]{8,}))(?-u:\b)'''
1193
1194[recognizers.context]
1195exclusions = ["test.local"]
1196
1197[recognizers.validator]
1198kind = "email_rfc"
1199
1200[recognizers.normalizer]
1201kind = "email_canonical"
1202
1203[recognizers.scoring]
1204base = 0.70
1205priority = 90
1206
1207[recognizers.token]
1208
1209[recognizers.source]
1210origin = "ported"
1211from = "presidio"
1212license = "Apache-2.0"
1213"#;
1214
1215    #[test]
1216    fn parses_core_rulepack_end_to_end() {
1217        let rulepack = Rulepack::parse(CORE).expect("core rulepack");
1218
1219        assert_eq!(rulepack.rulepack_id, "gaze-core");
1220        assert_eq!(rulepack.default_locales, vec![LocaleTag::Global]);
1221        let header_names = &rulepack
1222            .locale
1223            .as_ref()
1224            .and_then(|locale| locale.buckets.get("email_headers"))
1225            .expect("email headers")
1226            .names;
1227        assert_eq!(
1228            header_names,
1229            &vec!["From", "To", "Cc", "Bcc", "Reply-To", "Sender"]
1230        );
1231        assert_eq!(rulepack.recognizers.len(), 1);
1232        let recognizer = &rulepack.recognizers[0];
1233        assert_eq!(recognizer.id, "email.global");
1234        assert_eq!(recognizer.class, PiiClass::Email);
1235        assert_eq!(recognizer.scoring.priority, 90);
1236        assert!(matches!(recognizer.matcher, RawMatch::Regex { .. }));
1237    }
1238
1239    #[test]
1240    fn parses_nested_locale_cue_bundles() {
1241        let rulepack = Rulepack::parse(
1242            r#"
1243schema_version = "0.1.0"
1244rulepack_id = "locale-cues"
1245rulepack_version = "0.7.0"
1246default_locales = ["en-US"]
1247
1248[locale.forward_markers]
1249names = ["Forwarded message"]
1250
1251[locale.cues.iban]
1252names = ["IBAN:", "Account"]
1253window_chars = 48
1254"#,
1255        )
1256        .expect("locale cues rulepack");
1257
1258        let locale = rulepack.locale.expect("locale data");
1259        assert_eq!(
1260            locale.buckets["forward_markers"].names,
1261            vec!["Forwarded message"]
1262        );
1263        assert_eq!(locale.cues["iban"].names, vec!["IBAN:", "Account"]);
1264        assert_eq!(locale.cues["iban"].window_chars, Some(48));
1265    }
1266
1267    #[test]
1268    fn locale_cue_bundle_constructor_builds_public_type() {
1269        let bundle = LocaleCueBundle::new(vec!["IBAN".to_string()], Some(64));
1270
1271        assert_eq!(bundle.names, vec!["IBAN"]);
1272        assert_eq!(bundle.window_chars, Some(64));
1273    }
1274
1275    #[cfg(feature = "bundled-recognizers")]
1276    #[test]
1277    fn embedded_core_activated_classes_match_rulepack_classes() {
1278        let rulepack = Rulepack::load(RulepackSource::Embedded(
1279            gaze_recognizers::embedded("core").expect("core rulepack"),
1280        ))
1281        .expect("embedded core rulepack");
1282
1283        assert_eq!(
1284            rulepack.activated_classes(),
1285            BTreeSet::from([
1286                PiiClass::Email,
1287                PiiClass::Name,
1288                PiiClass::custom("phone"),
1289                PiiClass::custom("iban"),
1290                PiiClass::Custom("family:payment-card-or-iban".to_string()),
1291                PiiClass::custom("credit_card"),
1292                PiiClass::custom("ip_address"),
1293                PiiClass::custom("eth_address"),
1294                PiiClass::custom("aadhaar"),
1295                PiiClass::custom("nir"),
1296                PiiClass::custom("steuer_id"),
1297                PiiClass::custom("vat_id"),
1298                PiiClass::custom("bsn"),
1299                PiiClass::custom("cpf"),
1300                PiiClass::custom("cnpj"),
1301                PiiClass::custom("nhs_number"),
1302                PiiClass::custom("ssn"),
1303                PiiClass::custom("nino"),
1304                PiiClass::custom("pan"),
1305                PiiClass::custom("postal_code"),
1306            ])
1307        );
1308    }
1309
1310    #[cfg(feature = "bundled-recognizers")]
1311    #[test]
1312    fn embedded_core_loads_full_name_recognizer_cooperation_matrix() {
1313        let rulepack = Rulepack::load(RulepackSource::Embedded(
1314            gaze_recognizers::embedded("core").expect("core rulepack"),
1315        ))
1316        .expect("embedded core rulepack");
1317        let name_recognizers = rulepack
1318            .recognizers
1319            .iter()
1320            .filter(|recognizer| recognizer.class == PiiClass::Name)
1321            .collect::<Vec<_>>();
1322
1323        assert_eq!(name_recognizers.len(), 5);
1324        for recognizer in &name_recognizers {
1325            for peer in &name_recognizers {
1326                if recognizer.id == peer.id {
1327                    continue;
1328                }
1329                assert!(
1330                    recognizer.cooperates_with.contains(&peer.id),
1331                    "{} missing cooperates_with {}",
1332                    recognizer.id,
1333                    peer.id
1334                );
1335            }
1336        }
1337    }
1338
1339    #[cfg(feature = "bundled-recognizers")]
1340    #[test]
1341    fn embedded_core_extended_alias_activated_classes_match_core() {
1342        let rulepack = Rulepack::load(RulepackSource::Embedded(
1343            gaze_recognizers::embedded("core-extended").expect("core-extended rulepack"),
1344        ))
1345        .expect("embedded core-extended rulepack");
1346        let core = Rulepack::load(RulepackSource::Embedded(
1347            gaze_recognizers::embedded("core").expect("core rulepack"),
1348        ))
1349        .expect("embedded core rulepack");
1350
1351        assert_eq!(rulepack.activated_classes(), core.activated_classes());
1352    }
1353
1354    #[cfg(feature = "bundled-recognizers")]
1355    #[test]
1356    fn activated_classes_include_new_rulepack_recognizer_class() {
1357        let raw = format!(
1358            r#"{}
1359
1360[[recognizers]]
1361id = "test.only"
1362class = "custom:test_only"
1363enabled = true
1364locales = ["global"]
1365
1366[recognizers.match]
1367kind = "regex"
1368pattern = "TEST_ONLY"
1369
1370[recognizers.scoring]
1371base = 0.70
1372priority = 1
1373"#,
1374            gaze_recognizers::embedded("core-extended").expect("core-extended rulepack")
1375        );
1376        let rulepack = Rulepack::parse(&raw).expect("core-extended with synthetic recognizer");
1377
1378        assert!(
1379            rulepack
1380                .activated_classes()
1381                .contains(&PiiClass::custom("test_only")),
1382            "new recognizer class must be derived from rulepack data"
1383        );
1384    }
1385
1386    #[test]
1387    fn rulepack_accepts_token_family() {
1388        let rulepack = Rulepack::parse(&unsupported_field_rulepack(
1389            "[recognizers.token]\nfamily = \"email.formatpreserve\"\n",
1390        ))
1391        .expect("token family is active in v0.4.1");
1392
1393        assert_eq!(
1394            rulepack.recognizers[0].token.family.as_deref(),
1395            Some("email.formatpreserve")
1396        );
1397    }
1398
1399    #[test]
1400    fn rulepack_accepts_collision_membership_and_extends_family_cooperation() {
1401        let rulepack = Rulepack::parse(&collision_rulepack(
1402            r#"
1403[recognizers.collision]
1404family = "tenant-document"
1405variant = "alpha"
1406precedence = 10
1407"#,
1408            r#"
1409[recognizers.collision]
1410family = "tenant-document"
1411variant = "beta"
1412precedence = 20
1413"#,
1414        ))
1415        .expect("collision membership should parse");
1416
1417        assert_eq!(
1418            rulepack.recognizers[0].collision,
1419            Some(CollisionMembership::new(
1420                "tenant-document",
1421                "alpha",
1422                10,
1423                None,
1424            ))
1425        );
1426        assert_eq!(rulepack.recognizers[0].cooperates_with, vec!["tenant.beta"]);
1427        assert_eq!(
1428            rulepack.recognizers[1].cooperates_with,
1429            vec!["tenant.alpha"]
1430        );
1431    }
1432
1433    #[test]
1434    fn rulepack_rejects_inconsistent_collision_variant_precedence() {
1435        let err = Rulepack::parse(&collision_rulepack(
1436            r#"
1437[recognizers.collision]
1438family = "tenant-document"
1439variant = "alpha"
1440precedence = 10
1441"#,
1442            r#"
1443[recognizers.collision]
1444family = "tenant-document"
1445variant = "alpha"
1446precedence = 20
1447"#,
1448        ))
1449        .expect_err("same variant must use one precedence");
1450
1451        assert!(matches!(
1452            err,
1453            RulepackError::InconsistentCollisionVariant {
1454                family,
1455                variant,
1456                first_precedence: 10,
1457                second_precedence: 20,
1458                ..
1459            } if family == "tenant-document" && variant == "alpha"
1460        ));
1461    }
1462
1463    #[test]
1464    fn rulepack_rejects_ambiguous_family_precedence() {
1465        let err = Rulepack::parse(&collision_rulepack(
1466            r#"
1467[recognizers.collision]
1468family = "tenant-document"
1469variant = "alpha"
1470precedence = 10
1471"#,
1472            r#"
1473[recognizers.collision]
1474family = "tenant-document"
1475variant = "beta"
1476precedence = 10
1477"#,
1478        ))
1479        .expect_err("distinct variants cannot share precedence");
1480
1481        assert!(matches!(
1482            err,
1483            RulepackError::AmbiguousFamilyPrecedence {
1484                family,
1485                first_variant,
1486                second_variant,
1487                precedence: 10,
1488            } if family == "tenant-document"
1489                && first_variant == "alpha"
1490                && second_variant == "beta"
1491        ));
1492    }
1493
1494    #[test]
1495    fn rulepack_rejects_unsupported_token_format() {
1496        let err = Rulepack::parse(&unsupported_field_rulepack(
1497            "[recognizers.token]\nformat = \"Customer_{n}\"\n",
1498        ))
1499        .expect_err("token format is reserved for v0.4.1");
1500
1501        assert_unsupported_field(err, "token.format");
1502    }
1503
1504    #[test]
1505    fn rulepack_rejects_unsupported_context_hotwords() {
1506        let err = Rulepack::parse(&unsupported_field_rulepack(
1507            "[recognizers.context]\nhotwords = [\"foo\"]\n",
1508        ))
1509        .expect_err("context hotwords are reserved for v0.4.1");
1510
1511        assert_unsupported_field(err, "context.hotwords");
1512    }
1513
1514    #[test]
1515    fn rulepack_rejects_unsupported_context_boost() {
1516        let err = Rulepack::parse(&unsupported_field_rulepack(
1517            "[recognizers.context]\nboost = 0.10\n",
1518        ))
1519        .expect_err("context boost is reserved for v0.4.1");
1520
1521        assert_unsupported_field(err, "context.boost");
1522    }
1523
1524    #[test]
1525    fn rulepack_rejects_unsupported_context_window() {
1526        let err = Rulepack::parse(&unsupported_field_rulepack(
1527            "[recognizers.context]\nwindow = 12\n",
1528        ))
1529        .expect_err("context window is reserved for v0.4.1");
1530
1531        assert_unsupported_field(err, "context.window");
1532    }
1533
1534    #[test]
1535    fn rulepack_accepts_default_token_fields() {
1536        let rulepack = Rulepack::parse(CORE).expect("reserved token/context fields are unset");
1537        let recognizer = &rulepack.recognizers[0];
1538
1539        assert_eq!(recognizer.token.family, None);
1540        assert_eq!(recognizer.token.format, None);
1541        assert!(recognizer.context.as_ref().unwrap().hotwords.is_empty());
1542        assert_eq!(recognizer.context.as_ref().unwrap().boost, None);
1543        assert_eq!(recognizer.context.as_ref().unwrap().window, None);
1544    }
1545
1546    #[test]
1547    fn pattern_template_with_pattern_both_present_fails_closed() {
1548        let err = Rulepack::parse(&unsupported_field_rulepack(
1549            "pattern_template = \"{locale_email_headers}: (.+)\"\n",
1550        ))
1551        .expect_err("pattern and pattern_template are mutually exclusive");
1552
1553        assert!(matches!(
1554            err,
1555            RulepackError::RegexPatternChoice { id } if id == "bad.email"
1556        ));
1557    }
1558
1559    #[test]
1560    fn regex_pattern_or_template_is_required() {
1561        let raw = r#"
1562schema_version = "0.1.0"
1563rulepack_id = "bad"
1564rulepack_version = "0.4.0"
1565default_locales = ["global"]
1566
1567[[recognizers]]
1568id = "bad.email"
1569class = "Email"
1570enabled = true
1571
1572[recognizers.match]
1573kind = "regex"
1574"#;
1575        let err = Rulepack::parse(raw).expect_err("regex pattern is required");
1576
1577        assert!(matches!(
1578            err,
1579            RulepackError::RegexPatternChoice { id } if id == "bad.email"
1580        ));
1581    }
1582
1583    #[test]
1584    fn rulepack_load_accepts_fixture_email_regex() {
1585        let raw = r#"
1586schema_version = "0.1.0"
1587rulepack_id = "custom-email"
1588rulepack_version = "0.7.0"
1589default_locales = ["global"]
1590
1591[[recognizers]]
1592id = "custom.email"
1593class = "Email"
1594enabled = true
1595
1596[recognizers.match]
1597kind = "regex"
1598pattern = '''alice@example\.invalid'''
1599"#;
1600
1601        let rulepack = Rulepack::parse(raw).expect("standard email regex should load");
1602
1603        assert_eq!(rulepack.recognizers.len(), 1);
1604        assert_eq!(rulepack.recognizers[0].id, "custom.email");
1605    }
1606
1607    #[test]
1608    fn anchored_match_accepts_valid_schema() {
1609        let rulepack = Rulepack::parse(&anchored_match_rulepack("")).expect("anchored_match");
1610        assert!(matches!(
1611            rulepack.recognizers[0].matcher,
1612            RawMatch::AnchoredMatch { .. }
1613        ));
1614    }
1615
1616    #[test]
1617    fn anchored_match_rejects_unknown_boundary() {
1618        let err = Rulepack::parse(&anchored_match_rulepack("boundary = \"paragraph\"\n"))
1619            .expect_err("unknown boundary fails closed");
1620
1621        assert_unsupported_anchored_match(err, "boundary", "paragraph");
1622    }
1623
1624    #[test]
1625    fn anchored_match_rejects_unknown_name_shape() {
1626        let err = Rulepack::parse(&anchored_match_rulepack("name_shape = \"organization\"\n"))
1627            .expect_err("unknown name_shape fails closed");
1628
1629        assert_unsupported_anchored_match(err, "name_shape", "organization");
1630    }
1631
1632    #[test]
1633    fn anchored_match_rejects_unknown_cue_position() {
1634        let err = Rulepack::parse(&anchored_match_rulepack("cue_position = \"around\"\n"))
1635            .expect_err("unknown cue_position fails closed");
1636
1637        assert_unsupported_anchored_match(err, "cue_position", "around");
1638    }
1639
1640    #[test]
1641    fn anchored_match_rejects_missing_cues_bucket() {
1642        let err = Rulepack::parse(&anchored_match_rulepack("cues_bucket = \"\"\n"))
1643            .expect_err("missing cues_bucket fails closed");
1644
1645        assert_unsupported_anchored_match(err, "cues_bucket", "");
1646    }
1647
1648    #[test]
1649    fn anchored_match_rejects_ellipsis_in_cue_values() {
1650        let err = Rulepack::parse(
1651            r#"
1652schema_version = "0.1.0"
1653rulepack_id = "anchored"
1654rulepack_version = "0.6.0"
1655default_locales = ["global"]
1656
1657[locale.forward_markers]
1658names = ["Forwarded ... message"]
1659
1660[[recognizers]]
1661id = "name.forward_marker"
1662class = "Name"
1663enabled = true
1664
1665[recognizers.match]
1666kind = "anchored_match"
1667cues_bucket = "forward_markers"
1668boundary = "punctuation"
1669right_window_chars = 64
1670name_shape = "person_name"
1671cue_position = "before"
1672"#,
1673        )
1674        .expect_err("ellipsis cue fails closed");
1675
1676        assert_unsupported_anchored_match(
1677            err,
1678            "locale.forward_markers.names",
1679            "Forwarded ... message",
1680        );
1681    }
1682
1683    #[test]
1684    fn anchored_match_rejects_invalid_window_bounds() {
1685        for (value, expected) in [("0", "0"), ("513", "513")] {
1686            let err = Rulepack::parse(&anchored_match_rulepack(&format!(
1687                "right_window_chars = {value}\n"
1688            )))
1689            .expect_err("invalid right_window_chars fails closed");
1690
1691            assert_unsupported_anchored_match(err, "right_window_chars", expected);
1692        }
1693    }
1694
1695    #[test]
1696    fn rulepack_load_fails_when_two_name_recognizers_omit_cooperates_with() {
1697        let err = Rulepack::parse(
1698            r#"
1699schema_version = "0.1.0"
1700rulepack_id = "bad-composition"
1701rulepack_version = "0.4.1"
1702default_locales = ["global"]
1703
1704[[recognizers]]
1705id = "email.header.name"
1706class = "Name"
1707enabled = true
1708
1709[recognizers.match]
1710kind = "regex"
1711pattern = "From: ([A-Z][a-z]+)"
1712
1713[[recognizers]]
1714id = "salutation.name"
1715class = "Name"
1716enabled = true
1717
1718[recognizers.match]
1719kind = "regex"
1720pattern = "Dear ([A-Z][a-z]+)"
1721"#,
1722        )
1723        .expect_err("same-class recognizers must explicitly cooperate");
1724
1725        assert!(matches!(
1726            err,
1727            RulepackError::SameClassWithoutCooperation {
1728                class: PiiClass::Name,
1729                recognizer_a,
1730                recognizer_b,
1731            } if recognizer_a == "email.header.name" && recognizer_b == "salutation.name"
1732        ));
1733    }
1734
1735    #[test]
1736    fn rulepack_load_accepts_same_class_pair_with_cooperates_with() {
1737        let rulepack = Rulepack::parse(
1738            r#"
1739schema_version = "0.1.0"
1740rulepack_id = "cooperating-composition"
1741rulepack_version = "0.4.1"
1742default_locales = ["global"]
1743
1744[[recognizers]]
1745id = "email.header.name"
1746class = "Name"
1747cooperates_with = ["salutation.name"]
1748enabled = true
1749
1750[recognizers.match]
1751kind = "regex"
1752pattern = "From: ([A-Z][a-z]+)"
1753
1754[[recognizers]]
1755id = "salutation.name"
1756class = "Name"
1757enabled = true
1758
1759[recognizers.match]
1760kind = "regex"
1761pattern = "Dear ([A-Z][a-z]+)"
1762"#,
1763        )
1764        .expect("cooperates_with unblocks same-class recognizers");
1765
1766        assert_eq!(rulepack.recognizers.len(), 2);
1767        assert_eq!(
1768            rulepack.recognizers[0].cooperates_with,
1769            vec!["salutation.name"]
1770        );
1771    }
1772
1773    #[test]
1774    fn rejects_unknown_fields_with_parent_table_context() {
1775        let err = Rulepack::parse(
1776            r#"
1777schema_version = "0.1.0"
1778rulepack_id = "bad"
1779rulepack_version = "0.4.0"
1780default_locales = ["global"]
1781bogus = true
1782"#,
1783        )
1784        .expect_err("unknown field must fail");
1785
1786        assert!(matches!(err, RulepackError::Toml(_)));
1787        assert!(err.to_string().contains("bogus"));
1788    }
1789
1790    #[test]
1791    fn rejects_unsupported_schema_version() {
1792        let err = Rulepack::parse(
1793            r#"
1794schema_version = "0.2.0"
1795rulepack_id = "bad"
1796rulepack_version = "0.4.0"
1797"#,
1798        )
1799        .expect_err("unsupported schema");
1800
1801        assert!(matches!(err, RulepackError::SchemaVersion { .. }));
1802    }
1803
1804    #[test]
1805    fn class_spelling_accepts_pascal_case_and_custom_names() {
1806        assert_eq!(parse_class("Email").unwrap(), PiiClass::Email);
1807        assert_eq!(
1808            parse_class("custom:Class_Alpha").unwrap(),
1809            PiiClass::Custom("class_alpha".to_string())
1810        );
1811    }
1812
1813    fn unsupported_field_rulepack(extra: &str) -> String {
1814        format!(
1815            r#"
1816schema_version = "0.1.0"
1817rulepack_id = "bad"
1818rulepack_version = "0.4.0"
1819default_locales = ["global"]
1820
1821[[recognizers]]
1822id = "bad.email"
1823class = "Email"
1824enabled = true
1825
1826[recognizers.match]
1827kind = "regex"
1828pattern = "BAD_EMAIL_FIXTURE"
1829
1830{extra}
1831"#
1832        )
1833    }
1834
1835    fn collision_rulepack(first_collision: &str, second_collision: &str) -> String {
1836        format!(
1837            r#"
1838schema_version = "0.1.0"
1839rulepack_id = "collision-test"
1840rulepack_version = "0.7.0"
1841default_locales = ["global"]
1842
1843[[recognizers]]
1844id = "tenant.alpha"
1845class = "custom:tenant_alpha"
1846enabled = true
1847
1848[recognizers.match]
1849kind = "regex"
1850pattern = "ALPHA-[0-9]+"
1851
1852{first_collision}
1853
1854[[recognizers]]
1855id = "tenant.beta"
1856class = "custom:tenant_beta"
1857enabled = true
1858
1859[recognizers.match]
1860kind = "regex"
1861pattern = "BETA-[0-9]+"
1862
1863{second_collision}
1864"#
1865        )
1866    }
1867
1868    fn anchored_match_rulepack(override_line: &str) -> String {
1869        let cues_bucket = if override_line.starts_with("cues_bucket") {
1870            override_line.to_string()
1871        } else {
1872            "cues_bucket = \"forward_markers\"\n".to_string()
1873        };
1874        let boundary = if override_line.starts_with("boundary") {
1875            override_line.to_string()
1876        } else {
1877            "boundary = \"punctuation\"\n".to_string()
1878        };
1879        let right_window_chars = if override_line.starts_with("right_window_chars") {
1880            override_line.to_string()
1881        } else {
1882            "right_window_chars = 64\n".to_string()
1883        };
1884        let name_shape = if override_line.starts_with("name_shape") {
1885            override_line.to_string()
1886        } else {
1887            "name_shape = \"person_name\"\n".to_string()
1888        };
1889        let cue_position = if override_line.starts_with("cue_position") {
1890            override_line.to_string()
1891        } else {
1892            "cue_position = \"before\"\n".to_string()
1893        };
1894        format!(
1895            r#"
1896schema_version = "0.1.0"
1897rulepack_id = "anchored"
1898rulepack_version = "0.6.0"
1899default_locales = ["global"]
1900
1901[[recognizers]]
1902id = "name.forward_marker"
1903class = "Name"
1904enabled = true
1905
1906[recognizers.match]
1907kind = "anchored_match"
1908{cues_bucket}{boundary}{right_window_chars}{name_shape}{cue_position}
1909"#
1910        )
1911    }
1912
1913    fn assert_unsupported_field(err: RulepackError, field: &str) {
1914        assert!(matches!(
1915            err,
1916            RulepackError::UnsupportedField {
1917                field: ref actual,
1918                planned_version: "v0.4.1",
1919            } if actual == field
1920        ));
1921    }
1922
1923    fn assert_unsupported_anchored_match(err: RulepackError, field: &str, value: &str) {
1924        assert!(matches!(
1925            err,
1926            RulepackError::UnsupportedAnchoredMatch {
1927                field: ref actual_field,
1928                value: ref actual_value,
1929            } if actual_field == field && actual_value == value
1930        ));
1931    }
1932}