spectra_core/
classification.rs1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3
4pub const PII_MASK: &str = "***";
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct FieldClassification {
10 pub pii: bool,
12 pub safe_for_console: bool,
14 #[serde(skip_serializing_if = "Option::is_none")]
16 pub retention_days: Option<u32>,
17 #[serde(skip_serializing_if = "Option::is_none")]
19 pub purpose: Option<String>,
20}
21
22#[must_use]
54pub fn mask_field_value(classification: &FieldClassification, value: &Value) -> String {
55 if classification.pii || !classification.safe_for_console {
56 return PII_MASK.to_string();
57 }
58 match value {
59 Value::String(s) => s.clone(),
60 other => other.to_string(),
61 }
62}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67 use serde_json::json;
68
69 #[test]
70 fn classification_roundtrip() {
71 let c = FieldClassification {
72 pii: true,
73 safe_for_console: false,
74 retention_days: Some(30),
75 purpose: Some("debug".to_string()),
76 };
77 let json = serde_json::to_string(&c).expect("serialize");
78 let back: FieldClassification = serde_json::from_str(&json).expect("deserialize");
79 assert_eq!(back, c);
80 }
81
82 #[test]
83 fn mask_pii_hides_value() {
84 let c = FieldClassification {
85 pii: true,
86 safe_for_console: true,
87 retention_days: None,
88 purpose: None,
89 };
90 assert_eq!(mask_field_value(&c, &json!("secret")), PII_MASK);
91 }
92
93 #[test]
94 fn mask_unsafe_console_hides_value() {
95 let c = FieldClassification {
96 pii: false,
97 safe_for_console: false,
98 retention_days: None,
99 purpose: None,
100 };
101 assert_eq!(mask_field_value(&c, &json!(42)), PII_MASK);
102 }
103
104 #[test]
105 fn mask_safe_passthrough() {
106 let c = FieldClassification {
107 pii: false,
108 safe_for_console: true,
109 retention_days: None,
110 purpose: None,
111 };
112 assert_eq!(mask_field_value(&c, &json!("ok")), "ok");
113 assert_eq!(mask_field_value(&c, &json!(1)), "1");
114 }
115}