use serde::Deserialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub struct RedactPolicy {
#[serde(default)]
pub blocked_topics: HashSet<String>,
#[serde(default)]
pub allowed_topics: Vec<String>,
#[serde(default)]
pub topic_redaction_level: HashMap<String, RedactionLevel>,
#[serde(default)]
pub default_redaction_level: Option<RedactionLevel>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum RedactionLevel {
None,
Regex,
Semantic,
}
impl RedactPolicy {
pub fn is_blocked(&self, topic: &str) -> bool {
let t = topic.to_lowercase();
if self.blocked_topics.contains(&t) {
return true;
}
if !self.allowed_topics.is_empty() {
return !self.allowed_topics.iter().any(|a| a.to_lowercase() == t);
}
false
}
pub fn redaction_level(&self, topic: &str, edge_redact_active: bool) -> RedactionLevel {
let t = topic.to_lowercase();
if let Some(level) = self.topic_redaction_level.get(&t) {
return level.clone();
}
if let Some(ref default) = self.default_redaction_level {
return default.clone();
}
if edge_redact_active {
RedactionLevel::Regex
} else {
RedactionLevel::None
}
}
}
pub fn load_policy() -> RedactPolicy {
let workspace_path = Path::new(".hematite").join("redact_policy.json");
if let Some(policy) = try_load(&workspace_path) {
eprintln!(
"[hematite mcp] loaded redact policy from {}",
workspace_path.display()
);
return policy;
}
if let Some(home) = home_dir() {
let global_path = home.join(".hematite").join("redact_policy.json");
if let Some(policy) = try_load(&global_path) {
eprintln!(
"[hematite mcp] loaded redact policy from {}",
global_path.display()
);
return policy;
}
}
RedactPolicy::default()
}
fn try_load(path: &Path) -> Option<RedactPolicy> {
let text = std::fs::read_to_string(path).ok()?;
match serde_json::from_str::<RedactPolicy>(&text) {
Ok(p) => Some(p),
Err(e) => {
eprintln!(
"[hematite mcp] redact_policy parse error at {}: {e}",
path.display()
);
None
}
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("USERPROFILE")
.or_else(|| std::env::var_os("HOME"))
.map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use super::*;
fn policy_with_blocked(topics: &[&str]) -> RedactPolicy {
RedactPolicy {
blocked_topics: topics.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn blocks_exact_topic() {
let p = policy_with_blocked(&["user_accounts", "credentials"]);
assert!(p.is_blocked("user_accounts"));
assert!(p.is_blocked("credentials"));
assert!(!p.is_blocked("network"));
}
#[test]
fn block_check_is_case_insensitive() {
let p = policy_with_blocked(&["user_accounts"]);
assert!(p.is_blocked("User_Accounts"));
assert!(p.is_blocked("USER_ACCOUNTS"));
}
#[test]
fn whitelist_mode_blocks_unlisted_topics() {
let p = RedactPolicy {
allowed_topics: vec!["network".into(), "storage".into()],
..Default::default()
};
assert!(!p.is_blocked("network"));
assert!(!p.is_blocked("storage"));
assert!(p.is_blocked("user_accounts"));
assert!(p.is_blocked("credentials"));
}
#[test]
fn default_redaction_level_follows_edge_redact_flag() {
let p = RedactPolicy::default();
assert_eq!(p.redaction_level("network", true), RedactionLevel::Regex);
assert_eq!(p.redaction_level("network", false), RedactionLevel::None);
}
#[test]
fn per_topic_override_takes_precedence() {
let mut p = RedactPolicy::default();
p.topic_redaction_level
.insert("network".into(), RedactionLevel::Semantic);
assert_eq!(
p.redaction_level("network", false),
RedactionLevel::Semantic
);
assert_eq!(p.redaction_level("storage", true), RedactionLevel::Regex);
}
}