1use crate::license::Tier;
2use crate::verdict::{Finding, RuleId, Severity};
3
4pub struct RuleMeta {
9 pub rule_id: RuleId,
10 pub min_tier: Option<Tier>,
12 pub early_access_until: Option<&'static str>,
15}
16
17pub const RULE_META: &[RuleMeta] = &[
26 ];
34
35pub fn is_early_access_active(meta: &RuleMeta, now: chrono::NaiveDate) -> bool {
43 let Some(date_str) = meta.early_access_until else {
44 return false;
45 };
46 match chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
47 Ok(expiry) => now < expiry, Err(_) => {
49 eprintln!(
51 "tirith: warning: malformed early_access_until date \
52 for {:?}: {:?}",
53 meta.rule_id, date_str
54 );
55 false
56 }
57 }
58}
59
60pub fn filter_early_access(findings: &mut Vec<Finding>, tier: Tier) {
66 let today = chrono::Utc::now().date_naive();
67 filter_early_access_at(findings, tier, today);
68}
69
70pub fn filter_early_access_at(findings: &mut Vec<Finding>, tier: Tier, now: chrono::NaiveDate) {
72 filter_early_access_with(findings, tier, now, RULE_META);
73}
74
75pub fn filter_early_access_with(
77 findings: &mut Vec<Finding>,
78 tier: Tier,
79 now: chrono::NaiveDate,
80 rule_meta: &[RuleMeta],
81) {
82 findings.retain(|finding| {
83 let Some(meta) = rule_meta.iter().find(|m| m.rule_id == finding.rule_id) else {
84 return true; };
86
87 let Some(min_tier) = meta.min_tier else {
88 return true; };
90
91 if finding.severity == Severity::Critical {
93 return true;
94 }
95
96 if !is_early_access_active(meta, now) {
98 return true;
99 }
100
101 tier >= min_tier
103 });
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::verdict::Evidence;
110 use chrono::NaiveDate;
111
112 fn make_finding(rule_id: RuleId, severity: Severity) -> Finding {
113 Finding {
114 rule_id,
115 severity,
116 title: "test".into(),
117 description: "test".into(),
118 evidence: vec![Evidence::Text {
119 detail: "test".into(),
120 }],
121 human_view: None,
122 agent_view: None,
123 mitre_id: None,
124 custom_rule_id: None,
125 }
126 }
127
128 const TEST_RULE: RuleId = RuleId::ShortenedUrl;
131
132 fn test_meta(until: Option<&'static str>) -> RuleMeta {
133 RuleMeta {
134 rule_id: TEST_RULE,
135 min_tier: Some(Tier::Pro),
136 early_access_until: until,
137 }
138 }
139
140 #[test]
141 fn test_day_before_expiry_gate_active() {
142 let meta = test_meta(Some("2026-03-15"));
143 let day_before = NaiveDate::from_ymd_opt(2026, 3, 14).unwrap();
144 assert!(is_early_access_active(&meta, day_before));
145 }
146
147 #[test]
148 fn test_day_of_expiry_gate_expired() {
149 let meta = test_meta(Some("2026-03-15"));
151 let day_of = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap();
152 assert!(!is_early_access_active(&meta, day_of));
153 }
154
155 #[test]
156 fn test_day_after_expiry_gate_expired() {
157 let meta = test_meta(Some("2026-03-15"));
158 let day_after = NaiveDate::from_ymd_opt(2026, 3, 16).unwrap();
159 assert!(!is_early_access_active(&meta, day_after));
160 }
161
162 #[test]
163 fn test_none_date_means_no_gate() {
164 let meta = test_meta(None);
165 let any_date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
166 assert!(!is_early_access_active(&meta, any_date));
167 }
168
169 #[test]
170 fn test_malformed_date_fails_open() {
171 let meta = RuleMeta {
172 rule_id: TEST_RULE,
173 min_tier: Some(Tier::Pro),
174 early_access_until: Some("not-a-date"),
175 };
176 let any_date = NaiveDate::from_ymd_opt(2026, 6, 1).unwrap();
177 assert!(!is_early_access_active(&meta, any_date));
179 }
180
181 #[test]
182 fn test_critical_finding_bypasses_gate() {
183 let custom_meta = &[RuleMeta {
186 rule_id: TEST_RULE,
187 min_tier: Some(Tier::Pro),
188 early_access_until: Some("2099-12-31"),
189 }];
190 let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
191
192 let mut findings = vec![make_finding(TEST_RULE, Severity::Critical)];
193 filter_early_access_with(&mut findings, Tier::Community, now, custom_meta);
194 assert_eq!(
195 findings.len(),
196 1,
197 "Critical finding must bypass active early-access gate"
198 );
199 }
200
201 #[test]
202 fn test_filter_suppresses_medium_for_free_tier() {
203 let custom_meta = &[RuleMeta {
206 rule_id: TEST_RULE,
207 min_tier: Some(Tier::Pro),
208 early_access_until: Some("2099-12-31"),
209 }];
210 let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
211
212 let mut findings = vec![make_finding(TEST_RULE, Severity::Medium)];
213 filter_early_access_with(&mut findings, Tier::Community, now, custom_meta);
214 assert_eq!(
215 findings.len(),
216 0,
217 "Medium finding must be suppressed for Community tier"
218 );
219 }
220
221 #[test]
222 fn test_filter_passes_medium_for_pro_tier() {
223 let custom_meta = &[RuleMeta {
225 rule_id: TEST_RULE,
226 min_tier: Some(Tier::Pro),
227 early_access_until: Some("2099-12-31"),
228 }];
229 let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
230
231 let mut findings = vec![make_finding(TEST_RULE, Severity::Medium)];
232 filter_early_access_with(&mut findings, Tier::Pro, now, custom_meta);
233 assert_eq!(
234 findings.len(),
235 1,
236 "Medium finding must pass for Pro tier when gate requires Pro"
237 );
238 }
239
240 #[test]
241 fn test_filter_no_metadata_passes_through() {
242 let mut findings = vec![make_finding(TEST_RULE, Severity::Medium)];
244 let now = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
245 filter_early_access_with(&mut findings, Tier::Community, now, &[]);
246 assert_eq!(
247 findings.len(),
248 1,
249 "Finding with no metadata must always pass through"
250 );
251 }
252}