use crate::cli::MaskingArgs;
use crate::config::PipelineConfig;
use crate::error::{CliError, CliResult};
use faucet_core::masking::{CompiledMasking, MaskAction, MaskRule, MaskingSpec};
pub async fn run(args: MaskingArgs) -> CliResult<()> {
let cwd = std::env::current_dir()?;
let env_path =
crate::env_loader::resolve_env_file(args.env_file.as_deref(), args.no_env_file, &cwd)?;
crate::env_loader::load_env_file_if_present(env_path.as_deref())?;
let path = match args.config {
Some(p) => p,
None => crate::env_loader::discover_config_path(&cwd).ok_or(CliError::NoConfigOrFromEnv)?,
};
let cfg = PipelineConfig::from_path_tolerating_secrets(&path, args.profile.as_deref())?;
let spec = cfg.pipeline.masking.as_ref().ok_or_else(|| {
CliError::Config(
"no `pipeline.masking:` block in this config — add one, or run \
`faucet schema masking` to see the block's JSON Schema"
.to_string(),
)
})?;
CompiledMasking::compile(spec).map_err(|e| CliError::Config(format!("masking: {e}")))?;
let destinations = destinations(&cfg);
print!("{}", render_summary(spec, &destinations));
Ok(())
}
fn destinations(cfg: &PipelineConfig) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
if let Some(sink) = &cfg.pipeline.sink {
out.push(("default".to_string(), sink.kind.clone()));
}
for (name, spec) in &cfg.pipeline.sinks {
out.push((name.clone(), spec.kind.clone()));
}
out.sort();
out.dedup();
out
}
fn applied_rules(spec: &MaskingSpec, name: &str, kind: &str) -> Vec<String> {
spec.rules
.iter()
.enumerate()
.filter(|(_, r)| rule_applies(r, name, kind))
.map(|(i, r)| r.name.clone().unwrap_or_else(|| format!("rule_{i}")))
.collect()
}
fn rule_applies(rule: &MaskRule, name: &str, kind: &str) -> bool {
rule.applies_to.is_empty() || rule.applies_to.iter().any(|t| t == name || t == kind)
}
fn render_summary(spec: &MaskingSpec, destinations: &[(String, String)]) -> String {
use std::fmt::Write;
let mut out = String::new();
let n = spec.rules.len();
let _ = writeln!(
out,
"masking — valid ({n} rule{})",
if n == 1 { "" } else { "s" }
);
if let Some(d) = &spec.description {
let _ = writeln!(out, " description: {d}");
}
let _ = writeln!(
out,
" key: {}",
if spec.key.is_some() {
"configured (keyed HMAC-SHA256 for hash/tokenize)"
} else {
"none (unkeyed SHA-256 for hash/tokenize)"
}
);
let _ = writeln!(out, " rules:");
for (i, r) in spec.rules.iter().enumerate() {
let label = r.name.clone().unwrap_or_else(|| format!("rule_{i}"));
let scope = if r.applies_to.is_empty() {
"all sinks".to_string()
} else {
format!("sinks[{}]", r.applies_to.join(", "))
};
let _ = writeln!(
out,
" - {label}: {} → {} ({scope})",
describe_match(r),
describe_action(&r.action),
);
}
if destinations.is_empty() {
let _ = writeln!(
out,
" destinations: (none declared — every unscoped rule applies)"
);
} else {
let _ = writeln!(out, " destinations:");
for (name, kind) in destinations {
let applied = applied_rules(spec, name, kind);
let list = if applied.is_empty() {
"(no rules apply)".to_string()
} else {
applied.join(", ")
};
let _ = writeln!(out, " - {name} [{kind}]: {list}");
}
}
out
}
fn describe_match(rule: &MaskRule) -> String {
let m = &rule.matcher;
let mut parts: Vec<String> = Vec::new();
if let Some(p) = &m.field_pattern {
parts.push(format!("field_pattern /{p}/"));
}
if let Some(d) = m.value_detector {
parts.push(format!("detector {d}"));
}
if !m.fields.is_empty() {
parts.push(format!("fields[{}]", m.fields.join(", ")));
}
parts.join(" | ")
}
fn describe_action(action: &MaskAction) -> String {
match action {
MaskAction::Redact { .. } => "redact".to_string(),
MaskAction::Hash => "hash".to_string(),
MaskAction::Tokenize { prefix } => match prefix {
Some(p) => format!("tokenize (prefix '{p}')"),
None => "tokenize".to_string(),
},
MaskAction::Partial { keep_last, .. } => format!("partial (keep_last {keep_last})"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn spec() -> MaskingSpec {
serde_json::from_value(json!({
"description": "customer PII",
"key": "k",
"rules": [
{ "name": "emails", "match": { "value_detector": "email" },
"action": { "type": "redact" } },
{ "name": "ssn", "match": { "field_pattern": "(?i)ssn" },
"action": { "type": "hash" }, "applies_to": ["analytics"] },
{ "match": { "fields": ["card"] },
"action": { "type": "partial", "keep_last": 4 } }
]
}))
.unwrap()
}
#[test]
fn summary_lists_rules_key_and_scope() {
let dests = vec![
("default".to_string(), "postgres".to_string()),
("analytics".to_string(), "bigquery".to_string()),
];
let out = render_summary(&spec(), &dests);
assert!(out.contains("masking — valid (3 rules)"), "{out}");
assert!(out.contains("description: customer PII"), "{out}");
assert!(out.contains("keyed HMAC-SHA256"), "{out}");
assert!(
out.contains("emails: detector email → redact (all sinks)"),
"{out}"
);
assert!(
out.contains("ssn: field_pattern /(?i)ssn/ → hash (sinks[analytics])"),
"{out}"
);
assert!(
out.contains("rule_2: fields[card] → partial (keep_last 4)"),
"{out}"
);
}
#[test]
fn summary_shows_applied_rules_per_destination() {
let dests = vec![
("default".to_string(), "postgres".to_string()),
("analytics".to_string(), "bigquery".to_string()),
];
let out = render_summary(&spec(), &dests);
assert!(
out.contains("- default [postgres]: emails, rule_2"),
"{out}"
);
assert!(
out.contains("- analytics [bigquery]: emails, ssn, rule_2"),
"{out}"
);
}
#[test]
fn scope_matches_connector_kind_too() {
let s: MaskingSpec = serde_json::from_value(json!({
"rules": [{ "match": { "fields": ["x"] }, "action": { "type": "redact" },
"applies_to": ["bigquery"] }]
}))
.unwrap();
assert_eq!(applied_rules(&s, "warehouse", "bigquery"), vec!["rule_0"]);
assert!(applied_rules(&s, "warehouse", "postgres").is_empty());
}
#[test]
fn no_destinations_note() {
let out = render_summary(&spec(), &[]);
assert!(out.contains("none declared"), "{out}");
}
#[test]
fn unkeyed_and_tokenize_without_prefix_render() {
let s: MaskingSpec = serde_json::from_value(json!({
"rules": [{ "name": "tok", "match": { "fields": ["id"] },
"action": { "type": "tokenize" } }]
}))
.unwrap();
let out = render_summary(&s, &[("default".into(), "jsonl".into())]);
assert!(out.contains("masking — valid (1 rule)"), "{out}");
assert!(out.contains("none (unkeyed SHA-256"), "{out}");
assert!(
out.contains("tok: fields[id] → tokenize (all sinks)"),
"{out}"
);
assert!(out.contains("- default [jsonl]: tok"), "{out}");
}
#[test]
fn destinations_reads_singular_sink_and_named_sinks() {
use crate::config::PipelineConfig;
use std::path::Path;
let single = PipelineConfig::from_text(
r#"version: 1
pipeline:
source: { type: csv, config: { path: ./in.csv } }
masking: { rules: [ { match: { fields: [x] }, action: { type: redact } } ] }
sink: { type: jsonl, config: { path: ./out.jsonl } }
"#,
Path::new("test.yaml"),
)
.unwrap();
assert_eq!(
destinations(&single),
vec![("default".into(), "jsonl".into())]
);
let named = PipelineConfig::from_text(
r#"version: 1
pipeline:
source: { type: csv, config: { path: ./in.csv } }
masking: { rules: [ { match: { fields: [x] }, action: { type: redact } } ] }
sinks:
warehouse: { type: bigquery, config: {} }
archive: { type: jsonl, config: { path: ./a.jsonl } }
matrix:
- id: a
sink: { ref: archive }
- id: w
sink: { ref: warehouse }
"#,
Path::new("test.yaml"),
)
.unwrap();
assert_eq!(
destinations(&named),
vec![
("archive".into(), "jsonl".into()),
("warehouse".into(), "bigquery".into()),
]
);
}
}