specfold_core/guards/
terms.rs1use crate::config::{Config, GuardDef};
5use anyhow::{Context, Result};
6use regex::{Regex, RegexBuilder};
7
8pub fn load(cfg: &Config, g: &GuardDef) -> Result<Vec<String>> {
9 let mut terms: Vec<String> = g.deny_terms.clone();
10 if let Some(f) = &g.deny_terms_file {
11 let path = cfg.expand_path(f);
12 let text = std::fs::read_to_string(&path)
13 .with_context(|| format!("guard {}: cannot read its terms file {}", g.name, path.display()))?;
14 terms.extend(text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(str::to_string));
15 }
16 if let Some(var) = &g.deny_terms_env
17 && let Ok(v) = std::env::var(var)
18 {
19 terms.extend(v.split(['\n', ',']).map(str::trim).filter(|t| !t.is_empty()).map(str::to_string));
20 }
21 terms.sort();
22 terms.dedup();
23 Ok(terms)
24}
25
26pub fn matcher(terms: &[String]) -> Option<Regex> {
28 if terms.is_empty() {
29 return None;
30 }
31 let mut sorted: Vec<&String> = terms.iter().collect();
32 sorted.sort_by_key(|t| std::cmp::Reverse(t.len()));
33 let alt: Vec<String> = sorted.iter().map(|t| regex::escape(t)).collect();
34 RegexBuilder::new(&alt.join("|")).case_insensitive(true).build().ok()
35}
36
37pub fn mask(term: &str) -> String {
39 let chars: Vec<char> = term.chars().collect();
40 if chars.len() <= 4 {
41 return format!("{}*** ({})", chars.first().copied().unwrap_or('*'), chars.len());
42 }
43 let head: String = chars[..2].iter().collect();
44 let tail: String = chars[chars.len() - 2..].iter().collect();
45 format!("{head}***{tail} ({})", chars.len())
46}
47
48#[cfg(test)]
49mod tests {
50 use super::*;
51
52 #[test]
53 fn matches_and_masks() {
54 let re = matcher(&["acme".into(), "acme-internal".into()]).unwrap();
55 assert_eq!(re.find("see ACME-Internal docs").unwrap().as_str(), "ACME-Internal");
56 assert_eq!(mask("acme-internal"), "ac***al (13)");
57 assert_eq!(mask("abc"), "a*** (3)");
58 }
59}