Skip to main content

gaze/
policy.rs

1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5
6use serde::Deserialize;
7use thiserror::Error;
8
9use crate::{Action, LocaleTag, PiiClass, RulepackDict};
10
11pub const DEFAULT_NER_THRESHOLD: f32 = 0.3;
12
13/// Loaded redaction policy from a TOML configuration file.
14///
15/// Defines which rulepacks activate, which recognizers are enabled, and the locale chain.
16/// Load with [`Policy::load`] for library use or [`Policy::load_for_cli`] for CLI hosts.
17/// Both signatures take `&std::path::Path`.
18///
19/// Production deployments **must** use a policy -- the no-policy builder path is for
20/// development smoke-testing only and has an unauditable detection posture.
21///
22/// See `docs/policy.md` in the repository for the full TOML schema reference.
23#[derive(Debug, Clone, PartialEq, Default)]
24#[non_exhaustive]
25pub struct Policy {
26    pub session: SessionPolicy,
27    pub detectors: Vec<DetectorSpec>,
28    pub dictionaries: Vec<RulepackDict>,
29    pub rules: Vec<RuleSpec>,
30    pub ner: Option<NerPolicy>,
31    pub rulepacks: RulepackPolicy,
32    pub locale: Option<Vec<LocaleTag>>,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct SessionPolicy {
38    pub scope: SessionScope,
39    pub ttl_secs: Option<u64>,
40}
41
42impl Default for SessionPolicy {
43    fn default() -> Self {
44        Self {
45            scope: SessionScope::Ephemeral,
46            ttl_secs: None,
47        }
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum SessionScope {
54    Ephemeral,
55    Conversation,
56    Persistent,
57}
58
59impl SessionScope {
60    pub fn parse(value: &str) -> Result<Self, PolicyError> {
61        match value {
62            "ephemeral" => Ok(SessionScope::Ephemeral),
63            "conversation" => Ok(SessionScope::Conversation),
64            "persistent" => Ok(SessionScope::Persistent),
65            other => Err(PolicyError::SessionScopeUnknown {
66                value: other.to_string(),
67            }),
68        }
69    }
70}
71
72impl FromStr for SessionScope {
73    type Err = PolicyError;
74
75    fn from_str(value: &str) -> Result<Self, Self::Err> {
76        Self::parse(value)
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81#[non_exhaustive]
82pub struct DetectorSpec {
83    pub kind: DetectorKind,
84    pub name: String,
85    pub pattern: Option<String>,
86    pub class: PiiClass,
87    pub dictionary_name: Option<String>,
88    pub case_sensitive: bool,
89    pub token_family: String,
90}
91
92impl Default for DetectorSpec {
93    fn default() -> Self {
94        Self {
95            kind: DetectorKind::Regex,
96            name: String::new(),
97            pattern: None,
98            class: PiiClass::Email,
99            dictionary_name: None,
100            case_sensitive: false,
101            token_family: "counter".to_string(),
102        }
103    }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
107#[non_exhaustive]
108pub enum DetectorKind {
109    Regex,
110    Dictionary,
111    Unknown(String),
112}
113
114#[derive(Debug, Clone, PartialEq)]
115#[non_exhaustive]
116pub struct NerPolicy {
117    pub model_dir: Option<PathBuf>,
118    pub locale: Option<String>,
119    pub threshold: f32,
120}
121
122impl Default for NerPolicy {
123    fn default() -> Self {
124        Self {
125            model_dir: None,
126            locale: None,
127            threshold: DEFAULT_NER_THRESHOLD,
128        }
129    }
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Default)]
133#[non_exhaustive]
134pub struct RulepackPolicy {
135    pub bundled: Vec<String>,
136    pub paths: Vec<PathBuf>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
140#[non_exhaustive]
141pub enum RuleSpec {
142    Class { class: PiiClass, action: Action },
143    Column { column: String, action: Action },
144    Default { action: Action },
145}
146
147#[derive(Debug, Error)]
148#[non_exhaustive]
149pub enum PolicyError {
150    #[error("failed to parse policy.toml: {0}")]
151    TomlParse(#[source] toml::de::Error),
152    #[error("failed to read policy file: {0}")]
153    Io(#[source] std::io::Error),
154    #[error("unknown pii class: {0}")]
155    UnknownClass(String),
156    #[error("invalid regex for detector '{name}': {source}")]
157    BadRegex {
158        name: String,
159        #[source]
160        source: regex::Error,
161    },
162    #[error("invalid dictionary detector '{name}': {reason}")]
163    BadDictionary { name: String, reason: String },
164    #[error("session.ttl_secs is required when session.scope = \"persistent\"")]
165    MissingTtl,
166    #[error("invalid session.ttl_secs: {0}")]
167    BadTtl(String),
168    #[error("policy must define at least one rule")]
169    NoRules,
170    #[error("policy must define at least one detector")]
171    NoDetectors,
172    #[error(
173        "legacy [[detector]] is unsupported in v0.4; migrate to [[policy.custom_recognizers]]: {0}"
174    )]
175    LegacyDetectorUnsupported(&'static str),
176    #[error("ner load error: {0}")]
177    NerLoad(String),
178    #[error("ner.threshold must be between 0.0 and 1.0 inclusive, got {value}")]
179    NerThresholdOutOfRange { value: f32 },
180    #[error("session.scope must be one of ephemeral, conversation, persistent, got {value}")]
181    SessionScopeUnknown { value: String },
182    #[error("ner.locale must be a BCP47 locale tag, got {value}")]
183    NerLocaleUnsupported { value: String },
184    #[error("unknown bundled rulepack: {value}")]
185    BundledRulepackUnknown { value: String },
186    #[error("unknown locale bucket: {name}")]
187    UnknownLocaleBucket { name: String },
188    #[error("{0}")]
189    UnsupportedRuleKind(String),
190}
191
192impl Policy {
193    pub fn load(path: &Path) -> Result<Policy, PolicyError> {
194        let raw = fs::read_to_string(path).map_err(PolicyError::Io)?;
195        let raw: RawPolicy = toml::from_str(&raw).map_err(PolicyError::TomlParse)?;
196        raw.try_into()
197    }
198
199    pub fn load_for_cli(path: &Path) -> Result<Policy, PolicyError> {
200        let policy = Self::load(path)?;
201        if policy
202            .rules
203            .iter()
204            .any(|rule| matches!(rule, RuleSpec::Column { .. }))
205        {
206            return Err(PolicyError::UnsupportedRuleKind(
207                "column rules not supported in CLI mode".to_string(),
208            ));
209        }
210        Ok(policy)
211    }
212}
213
214#[derive(Debug, Deserialize)]
215#[serde(deny_unknown_fields)]
216struct RawPolicy {
217    session: RawSessionPolicy,
218    #[serde(rename = "detector", default)]
219    detectors: Vec<RawDetectorSpec>,
220    #[serde(rename = "rule", default)]
221    rules: Vec<RawRuleSpec>,
222    #[serde(default)]
223    ner: Option<RawNerPolicy>,
224    #[serde(default)]
225    locale: Option<RawLocalePolicy>,
226    #[serde(default)]
227    policy: Option<RawPolicyTables>,
228}
229
230#[derive(Debug, Deserialize)]
231#[serde(deny_unknown_fields)]
232struct RawSessionPolicy {
233    scope: String,
234    ttl_secs: Option<u64>,
235}
236
237#[derive(Debug, Deserialize)]
238#[serde(deny_unknown_fields)]
239struct RawDetectorSpec {
240    kind: String,
241    name: String,
242    pattern: Option<String>,
243    class: String,
244    dictionary: Option<String>,
245    #[serde(default)]
246    terms: Vec<String>,
247    terms_file: Option<String>,
248    terms_from_context: Option<String>,
249    #[serde(default)]
250    case_sensitive: bool,
251    token_family: Option<String>,
252}
253
254#[derive(Debug, Deserialize)]
255#[serde(deny_unknown_fields)]
256struct RawNerPolicy {
257    model_dir: Option<String>,
258    locale: Option<String>,
259    #[serde(default)]
260    threshold: Option<f32>,
261}
262
263#[derive(Debug, Deserialize)]
264#[serde(deny_unknown_fields)]
265struct RawLocalePolicy {
266    #[serde(default)]
267    active: Vec<String>,
268}
269
270#[derive(Debug, Default, Deserialize)]
271#[serde(deny_unknown_fields)]
272struct RawPolicyTables {
273    #[serde(default)]
274    rulepacks: Option<RawRulepackPolicy>,
275    #[serde(default)]
276    custom_recognizers: Vec<RawDetectorSpec>,
277}
278
279#[derive(Debug, Deserialize)]
280#[serde(deny_unknown_fields)]
281struct RawRulepackPolicy {
282    #[serde(default)]
283    bundled: Vec<String>,
284    #[serde(default)]
285    paths: Vec<String>,
286}
287
288#[derive(Debug, Deserialize)]
289#[serde(deny_unknown_fields)]
290struct RawRuleSpec {
291    kind: String,
292    class: Option<String>,
293    column: Option<String>,
294    action: String,
295}
296
297impl TryFrom<RawPolicy> for Policy {
298    type Error = PolicyError;
299
300    fn try_from(raw: RawPolicy) -> Result<Self, Self::Error> {
301        let session = parse_session(raw.session)?;
302
303        if !raw.detectors.is_empty() {
304            return Err(PolicyError::LegacyDetectorUnsupported(
305                "https://github.com/EmpireTwo/gaze/blob/main/docs/policy.md#migrating-detector",
306            ));
307        }
308
309        let policy_tables = raw.policy.unwrap_or_default();
310        let RawPolicyTables {
311            rulepacks: raw_rulepacks,
312            custom_recognizers,
313        } = policy_tables;
314
315        let mut detectors = Vec::with_capacity(custom_recognizers.len());
316        let mut dictionaries = Vec::new();
317        for detector in custom_recognizers {
318            let (detector, dictionary) = parse_detector(detector)?;
319            if let Some(dictionary) = dictionary {
320                dictionaries.push(dictionary);
321            }
322            detectors.push(detector);
323        }
324        let rulepacks = raw_rulepacks
325            .map(parse_rulepack_policy)
326            .transpose()?
327            .unwrap_or_else(|| RulepackPolicy {
328                bundled: vec!["core".to_string()],
329                paths: Vec::new(),
330            });
331
332        if detectors.is_empty() && rulepacks.bundled.is_empty() && rulepacks.paths.is_empty() {
333            return Err(PolicyError::NoDetectors);
334        }
335
336        let mut rules = Vec::with_capacity(raw.rules.len());
337        for rule in raw.rules {
338            rules.push(parse_rule(rule)?);
339        }
340        if rules.is_empty() {
341            return Err(PolicyError::NoRules);
342        }
343
344        let ner = raw.ner.map(parse_ner).transpose()?;
345        let locale = raw.locale.map(parse_locale_policy).transpose()?.flatten();
346
347        Ok(Self {
348            session,
349            detectors,
350            dictionaries,
351            rules,
352            ner,
353            rulepacks,
354            locale,
355        })
356    }
357}
358
359fn parse_session(raw: RawSessionPolicy) -> Result<SessionPolicy, PolicyError> {
360    let scope = SessionScope::parse(&raw.scope)?;
361
362    match scope {
363        SessionScope::Persistent => match raw.ttl_secs {
364            Some(0) => Err(PolicyError::BadTtl(
365                "session.ttl_secs must be greater than zero".to_string(),
366            )),
367            Some(ttl_secs) => Ok(SessionPolicy {
368                scope,
369                ttl_secs: Some(ttl_secs),
370            }),
371            None => Err(PolicyError::MissingTtl),
372        },
373        _ => {
374            if raw.ttl_secs == Some(0) {
375                return Err(PolicyError::BadTtl(
376                    "session.ttl_secs must be greater than zero".to_string(),
377                ));
378            }
379            Ok(SessionPolicy {
380                scope,
381                ttl_secs: raw.ttl_secs,
382            })
383        }
384    }
385}
386
387fn parse_detector(
388    raw: RawDetectorSpec,
389) -> Result<(DetectorSpec, Option<RulepackDict>), PolicyError> {
390    let class = parse_class(&raw.class)?;
391    match raw.kind.as_str() {
392        "regex" => parse_regex_detector(raw, class),
393        "dictionary" => parse_dictionary_detector(raw, class),
394        other => Ok((
395            DetectorSpec {
396                kind: DetectorKind::Unknown(other.to_string()),
397                name: raw.name,
398                pattern: raw.pattern,
399                class,
400                dictionary_name: None,
401                case_sensitive: raw.case_sensitive,
402                token_family: raw.token_family.unwrap_or_else(|| "counter".to_string()),
403            },
404            None,
405        )),
406    }
407}
408
409fn parse_regex_detector(
410    raw: RawDetectorSpec,
411    class: PiiClass,
412) -> Result<(DetectorSpec, Option<RulepackDict>), PolicyError> {
413    let pattern = raw.pattern.ok_or_else(|| PolicyError::BadDictionary {
414        name: raw.name.clone(),
415        reason: "regex recognizers require pattern".to_string(),
416    })?;
417    regex::Regex::new(&pattern).map_err(|source| PolicyError::BadRegex {
418        name: raw.name.clone(),
419        source,
420    })?;
421
422    Ok((
423        DetectorSpec {
424            kind: DetectorKind::Regex,
425            name: raw.name,
426            pattern: Some(pattern),
427            class,
428            dictionary_name: None,
429            case_sensitive: false,
430            token_family: raw.token_family.unwrap_or_else(|| "counter".to_string()),
431        },
432        None,
433    ))
434}
435
436fn parse_dictionary_detector(
437    raw: RawDetectorSpec,
438    class: PiiClass,
439) -> Result<(DetectorSpec, Option<RulepackDict>), PolicyError> {
440    if raw.pattern.is_some() {
441        return Err(PolicyError::BadDictionary {
442            name: raw.name,
443            reason: "dictionary recognizers must not set pattern".to_string(),
444        });
445    }
446
447    let dictionary_name = raw
448        .terms_from_context
449        .clone()
450        .or(raw.dictionary.clone())
451        .unwrap_or_else(|| raw.name.clone());
452    let mut terms = raw.terms;
453    if let Some(path) = raw.terms_file {
454        let path = expand_home(path)?;
455        let file = fs::read_to_string(&path).map_err(PolicyError::Io)?;
456        terms.extend(
457            file.lines()
458                .map(str::trim)
459                .filter(|line| !line.is_empty() && !line.starts_with('#'))
460                .map(str::to_string),
461        );
462    }
463
464    let dictionary = if raw.terms_from_context.is_some() {
465        if !terms.is_empty() {
466            return Err(PolicyError::BadDictionary {
467                name: raw.name.clone(),
468                reason: "terms_from_context cannot be combined with terms or terms_file"
469                    .to_string(),
470            });
471        }
472        None
473    } else {
474        if terms.is_empty() {
475            return Err(PolicyError::BadDictionary {
476                name: raw.name.clone(),
477                reason: "dictionary recognizers require terms, terms_file, or terms_from_context"
478                    .to_string(),
479            });
480        }
481        if !raw.case_sensitive && terms.iter().any(|term| !term.is_ascii()) {
482            return Err(PolicyError::BadDictionary {
483                name: raw.name.clone(),
484                reason:
485                    "unicode dictionary insensitive matching unsupported in v0.4.0, use case_sensitive = true"
486                        .to_string(),
487            });
488        }
489        Some(RulepackDict::new(
490            dictionary_name.clone(),
491            terms,
492            raw.case_sensitive,
493        ))
494    };
495
496    Ok((
497        DetectorSpec {
498            kind: DetectorKind::Dictionary,
499            name: raw.name,
500            pattern: None,
501            class,
502            dictionary_name: Some(dictionary_name),
503            case_sensitive: raw.case_sensitive,
504            token_family: raw.token_family.unwrap_or_else(|| "counter".to_string()),
505        },
506        dictionary,
507    ))
508}
509
510fn parse_rule(raw: RawRuleSpec) -> Result<RuleSpec, PolicyError> {
511    let action = parse_action(&raw.action)?;
512    match raw.kind.as_str() {
513        "class" => {
514            let class = raw
515                .class
516                .ok_or_else(|| PolicyError::UnknownClass("missing rule.class".to_string()))?;
517            Ok(RuleSpec::Class {
518                class: parse_class(&class)?,
519                action,
520            })
521        }
522        "column" => Ok(RuleSpec::Column {
523            column: raw
524                .column
525                .ok_or_else(|| PolicyError::BadTtl("missing rule.column".to_string()))?,
526            action,
527        }),
528        "default" => Ok(RuleSpec::Default { action }),
529        other => Err(PolicyError::BadTtl(format!("unknown rule.kind '{other}'"))),
530    }
531}
532
533fn parse_ner(raw: RawNerPolicy) -> Result<NerPolicy, PolicyError> {
534    let threshold = raw.threshold.unwrap_or(DEFAULT_NER_THRESHOLD);
535    if !(0.0..=1.0).contains(&threshold) {
536        return Err(PolicyError::NerThresholdOutOfRange { value: threshold });
537    }
538    if let Some(locale) = &raw.locale {
539        validate_ner_locale(locale)?;
540    }
541    Ok(NerPolicy {
542        model_dir: raw.model_dir.map(expand_home).transpose()?,
543        locale: raw.locale,
544        threshold,
545    })
546}
547
548pub fn validate_ner_locale(locale: &str) -> Result<(), PolicyError> {
549    LocaleTag::parse(locale)
550        .map(|_| ())
551        .map_err(|_| PolicyError::NerLocaleUnsupported {
552            value: locale.to_string(),
553        })
554}
555
556fn parse_locale_policy(raw: RawLocalePolicy) -> Result<Option<Vec<LocaleTag>>, PolicyError> {
557    if raw.active.is_empty() {
558        return Ok(None);
559    }
560    raw.active
561        .into_iter()
562        .map(|locale| {
563            LocaleTag::parse(&locale)
564                .map_err(|_| PolicyError::BadTtl(format!("unsupported locale tag '{locale}'")))
565        })
566        .collect::<Result<Vec<_>, _>>()
567        .map(Some)
568}
569
570fn parse_rulepack_policy(raw: RawRulepackPolicy) -> Result<RulepackPolicy, PolicyError> {
571    Ok(RulepackPolicy {
572        bundled: raw.bundled,
573        paths: raw
574            .paths
575            .into_iter()
576            .map(expand_home)
577            .collect::<Result<_, _>>()?,
578    })
579}
580
581fn expand_home(path: String) -> Result<PathBuf, PolicyError> {
582    if let Some(rest) = path.strip_prefix("~/") {
583        let home = env::var("HOME")
584            .map_err(|_| PolicyError::BadTtl("HOME is not set for ~/ expansion".to_string()))?;
585        Ok(PathBuf::from(home).join(rest))
586    } else {
587        Ok(PathBuf::from(path))
588    }
589}
590
591fn parse_class(input: &str) -> Result<PiiClass, PolicyError> {
592    let lower = input.trim().to_ascii_lowercase();
593    match lower.as_str() {
594        "email" => Ok(PiiClass::Email),
595        "name" => Ok(PiiClass::Name),
596        "location" => Ok(PiiClass::Location),
597        "organization" => Ok(PiiClass::Organization),
598        custom if custom.starts_with("custom:") => {
599            let name = input
600                .trim()
601                .split_once(':')
602                .map(|(_, name)| name)
603                .unwrap_or_default();
604            if name.trim().is_empty() {
605                return Err(PolicyError::UnknownClass(input.to_string()));
606            }
607            Ok(PiiClass::custom(name))
608        }
609        _ => Err(PolicyError::UnknownClass(input.to_string())),
610    }
611}
612
613fn parse_action(input: &str) -> Result<Action, PolicyError> {
614    match input {
615        "tokenize" => Ok(Action::Tokenize),
616        "redact" => Ok(Action::Redact),
617        "format_preserve" => Ok(Action::FormatPreserve),
618        "generalize" => Ok(Action::Generalize),
619        "preserve" => Ok(Action::Preserve),
620        other => Err(PolicyError::BadTtl(format!(
621            "unknown rule.action '{other}'"
622        ))),
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use std::fs;
629
630    use tempfile::tempdir;
631
632    use super::*;
633
634    #[test]
635    fn loads_policy_and_expands_home() {
636        let dir = tempdir().unwrap();
637        let path = dir.path().join("policy.toml");
638        fs::write(
639            &path,
640            r#"
641[session]
642scope = "persistent"
643ttl_secs = 86400
644
645[[policy.custom_recognizers]]
646kind = "regex"
647name = "emails"
648pattern = '(?i)\b[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}\b'
649class = "email"
650
651[ner]
652model_dir = "~/.cache/gaze/model"
653locale = "de"
654threshold = 0.4
655
656[[rule]]
657kind = "class"
658class = "email"
659action = "tokenize"
660
661[[rule]]
662kind = "default"
663action = "preserve"
664"#,
665        )
666        .unwrap();
667
668        let old_home = env::var_os("HOME");
669        env::set_var("HOME", "/tmp/gaze-home");
670        let policy = Policy::load(&path).unwrap();
671        match old_home {
672            Some(value) => env::set_var("HOME", value),
673            None => env::remove_var("HOME"),
674        }
675
676        assert_eq!(policy.session.scope, SessionScope::Persistent);
677        assert_eq!(policy.session.ttl_secs, Some(86400));
678        assert_eq!(policy.detectors.len(), 1);
679        assert_eq!(policy.rules.len(), 2);
680        let ner = policy.ner.unwrap();
681        assert_eq!(
682            ner.model_dir,
683            Some(PathBuf::from("/tmp/gaze-home/.cache/gaze/model"))
684        );
685        assert_eq!(ner.threshold, 0.4);
686    }
687
688    #[test]
689    fn rejects_ner_threshold_out_of_range() {
690        let raw = r#"
691[session]
692scope = "ephemeral"
693
694[ner]
695threshold = 1.1
696
697[[policy.custom_recognizers]]
698kind = "regex"
699name = "emails"
700pattern = ".+"
701class = "email"
702
703[[rule]]
704kind = "default"
705action = "preserve"
706"#;
707        let raw: RawPolicy = toml::from_str(raw).expect("raw policy");
708
709        assert!(matches!(
710            Policy::try_from(raw),
711            Err(PolicyError::NerThresholdOutOfRange { value }) if value == 1.1
712        ));
713    }
714
715    #[test]
716    fn accepts_bcp47_ner_locale_hints() {
717        for locale in ["de", "en-US", "pt-BR", "zh-Hant"] {
718            assert!(
719                validate_ner_locale(locale).is_ok(),
720                "NER locale hints should accept BCP47-shaped tag {locale}"
721            );
722        }
723
724        assert!(matches!(
725            validate_ner_locale("bad locale!"),
726            Err(PolicyError::NerLocaleUnsupported { value }) if value == "bad locale!"
727        ));
728    }
729
730    #[test]
731    fn rejects_unknown_session_scope_with_typed_error() {
732        let raw = r#"
733[session]
734scope = "forever"
735
736[[policy.custom_recognizers]]
737kind = "regex"
738name = "emails"
739pattern = ".+"
740class = "email"
741
742[[rule]]
743kind = "default"
744action = "preserve"
745"#;
746
747        let raw = toml::from_str::<RawPolicy>(raw).unwrap();
748        let err = Policy::try_from(raw).unwrap_err();
749
750        assert!(matches!(
751            err,
752            PolicyError::SessionScopeUnknown { value } if value == "forever"
753        ));
754    }
755
756    #[test]
757    fn rejects_unknown_keys() {
758        let dir = tempdir().unwrap();
759        let path = dir.path().join("policy.toml");
760        fs::write(
761            &path,
762            r#"
763[session]
764scope = "ephemeral"
765bogus = true
766
767[[policy.custom_recognizers]]
768kind = "regex"
769name = "emails"
770pattern = ".+"
771class = "email"
772
773[[rule]]
774kind = "default"
775action = "preserve"
776"#,
777        )
778        .unwrap();
779
780        assert!(matches!(
781            Policy::load(&path),
782            Err(PolicyError::TomlParse(_))
783        ));
784    }
785
786    #[test]
787    fn loads_dictionary_custom_recognizer_terms() {
788        let dir = tempdir().unwrap();
789        let path = dir.path().join("policy.toml");
790        fs::write(
791            &path,
792            r#"
793[session]
794scope = "ephemeral"
795
796[[policy.custom_recognizers]]
797kind = "dictionary"
798name = "songs"
799class = "custom:song"
800terms = ["Song A"]
801case_sensitive = true
802
803[[rule]]
804kind = "class"
805class = "custom:song"
806action = "tokenize"
807
808[[rule]]
809kind = "default"
810action = "preserve"
811"#,
812        )
813        .unwrap();
814
815        let policy = Policy::load(&path).unwrap();
816        assert_eq!(policy.detectors[0].kind, DetectorKind::Dictionary);
817        assert_eq!(
818            policy.detectors[0].dictionary_name.as_deref(),
819            Some("songs")
820        );
821        assert_eq!(policy.dictionaries[0].terms, vec!["Song A"]);
822    }
823}