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#[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}