use crate::license::Tier;
use crate::verdict::{Finding, RuleId, Severity};
pub struct RuleMeta {
pub rule_id: RuleId,
pub min_tier: Option<Tier>,
pub early_access_until: Option<&'static str>,
}
pub const RULE_META: &[RuleMeta] = &[
];
pub fn is_early_access_active(meta: &RuleMeta, now: chrono::NaiveDate) -> bool {
let Some(date_str) = meta.early_access_until else {
return false;
};
match chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
Ok(expiry) => now < expiry, Err(_) => {
eprintln!(
"tirith: warning: malformed early_access_until date \
for {:?}: {:?}",
meta.rule_id, date_str
);
false
}
}
}
pub fn filter_early_access(findings: &mut Vec<Finding>, tier: Tier) {
let today = chrono::Utc::now().date_naive();
filter_early_access_at(findings, tier, today);
}
pub fn filter_early_access_at(findings: &mut Vec<Finding>, tier: Tier, now: chrono::NaiveDate) {
filter_early_access_with(findings, tier, now, RULE_META);
}
pub fn filter_early_access_with(
findings: &mut Vec<Finding>,
tier: Tier,
now: chrono::NaiveDate,
rule_meta: &[RuleMeta],
) {
findings.retain(|finding| {
let Some(meta) = rule_meta.iter().find(|m| m.rule_id == finding.rule_id) else {
return true; };
let Some(min_tier) = meta.min_tier else {
return true; };
if finding.severity == Severity::Critical {
return true;
}
if !is_early_access_active(meta, now) {
return true;
}
tier >= min_tier
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::verdict::Evidence;
use chrono::NaiveDate;
fn make_finding(rule_id: RuleId, severity: Severity) -> Finding {
Finding {
rule_id,
severity,
title: "test".into(),
description: "test".into(),
evidence: vec![Evidence::Text {
detail: "test".into(),
}],
human_view: None,
agent_view: None,
mitre_id: None,
custom_rule_id: None,
}
}
const TEST_RULE: RuleId = RuleId::ShortenedUrl;
fn test_meta(until: Option<&'static str>) -> RuleMeta {
RuleMeta {
rule_id: TEST_RULE,
min_tier: Some(Tier::Pro),
early_access_until: until,
}
}
#[test]
fn test_day_before_expiry_gate_active() {
let meta = test_meta(Some("2026-03-15"));
let day_before = NaiveDate::from_ymd_opt(2026, 3, 14).unwrap();
assert!(is_early_access_active(&meta, day_before));
}
#[test]
fn test_day_of_expiry_gate_expired() {
let meta = test_meta(Some("2026-03-15"));
let day_of = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
assert!(!is_early_access_active(&meta, day_of));
}
#[test]
fn test_day_after_expiry_gate_expired() {
let meta = test_meta(Some("2026-03-15"));
let day_after = NaiveDate::from_ymd_opt(2026, 3, 16).unwrap();
assert!(!is_early_access_active(&meta, day_after));
}
#[test]
fn test_none_date_means_no_gate() {
let meta = test_meta(None);
let any_date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
assert!(!is_early_access_active(&meta, any_date));
}
#[test]
fn test_malformed_date_fails_open() {
let meta = RuleMeta {
rule_id: TEST_RULE,
min_tier: Some(Tier::Pro),
early_access_until: Some("not-a-date"),
};
let any_date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
assert!(!is_early_access_active(&meta, any_date));
}
#[test]
fn test_critical_finding_bypasses_gate() {
let custom_meta = &[RuleMeta {
rule_id: TEST_RULE,
min_tier: Some(Tier::Pro),
early_access_until: Some("2099-12-31"),
}];
let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut findings = vec![make_finding(TEST_RULE, Severity::Critical)];
filter_early_access_with(&mut findings, Tier::Community, now, custom_meta);
assert_eq!(
findings.len(),
1,
"Critical finding must bypass active early-access gate"
);
}
#[test]
fn test_filter_suppresses_medium_for_free_tier() {
let custom_meta = &[RuleMeta {
rule_id: TEST_RULE,
min_tier: Some(Tier::Pro),
early_access_until: Some("2099-12-31"),
}];
let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut findings = vec![make_finding(TEST_RULE, Severity::Medium)];
filter_early_access_with(&mut findings, Tier::Community, now, custom_meta);
assert_eq!(
findings.len(),
0,
"Medium finding must be suppressed for Community tier"
);
}
#[test]
fn test_filter_passes_medium_for_pro_tier() {
let custom_meta = &[RuleMeta {
rule_id: TEST_RULE,
min_tier: Some(Tier::Pro),
early_access_until: Some("2099-12-31"),
}];
let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut findings = vec![make_finding(TEST_RULE, Severity::Medium)];
filter_early_access_with(&mut findings, Tier::Pro, now, custom_meta);
assert_eq!(
findings.len(),
1,
"Medium finding must pass for Pro tier when gate requires Pro"
);
}
#[test]
fn test_filter_no_metadata_passes_through() {
let mut findings = vec![make_finding(TEST_RULE, Severity::Medium)];
let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
filter_early_access_with(&mut findings, Tier::Community, now, &[]);
assert_eq!(
findings.len(),
1,
"Finding with no metadata must always pass through"
);
}
}