|
2 | 2 | //! |
3 | 3 | //! Generates Rust code from rules.json at compile time. |
4 | 4 | //! Supports both local crate builds (crates.io) and workspace builds (development). |
| 5 | +//! |
| 6 | +//! Generated constants: |
| 7 | +//! - `RULES_DATA`: All rule (id, name) tuples |
| 8 | +//! - `VALID_TOOLS`: Unique tool names from evidence.applies_to.tool |
| 9 | +//! - `TOOL_RULE_PREFIXES`: Mapping of (prefix, tool) for tool-specific rules |
5 | 10 |
|
| 11 | +use std::collections::{BTreeMap, BTreeSet}; |
6 | 12 | use std::env; |
7 | 13 | use std::fs; |
8 | 14 | use std::path::{Path, PathBuf}; |
@@ -156,10 +162,122 @@ fn main() { |
156 | 162 | )); |
157 | 163 | } |
158 | 164 |
|
| 165 | + generated_code.push_str("];\n\n"); |
| 166 | + |
| 167 | + // ========================================================================= |
| 168 | + // Extract unique tools from evidence.applies_to.tool |
| 169 | + // ========================================================================= |
| 170 | + let mut tools: BTreeSet<String> = BTreeSet::new(); |
| 171 | + |
| 172 | + for rule in rules_array { |
| 173 | + if let Some(tool) = rule |
| 174 | + .get("evidence") |
| 175 | + .and_then(|e| e.get("applies_to")) |
| 176 | + .and_then(|a| a.get("tool")) |
| 177 | + .and_then(|t| t.as_str()) |
| 178 | + { |
| 179 | + if !tool.is_empty() { |
| 180 | + tools.insert(tool.to_string()); |
| 181 | + } |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + // Generate VALID_TOOLS constant |
| 186 | + generated_code |
| 187 | + .push_str("/// Valid tool names derived from rules.json evidence.applies_to.tool.\n"); |
| 188 | + generated_code.push_str("/// \n"); |
| 189 | + generated_code |
| 190 | + .push_str("/// These are the tools that have at least one rule specifically for them.\n"); |
| 191 | + generated_code.push_str("pub const VALID_TOOLS: &[&str] = &[\n"); |
| 192 | + for tool in &tools { |
| 193 | + generated_code.push_str(&format!(" \"{}\",\n", escape_str(tool))); |
| 194 | + } |
| 195 | + generated_code.push_str("];\n\n"); |
| 196 | + |
| 197 | + // ========================================================================= |
| 198 | + // Derive prefix-to-tool mappings from rule IDs |
| 199 | + // ========================================================================= |
| 200 | + // Group rules by their prefix and track: |
| 201 | + // 1. Which tools are specified for rules with this prefix |
| 202 | + // 2. Whether any rule with this prefix has NO tool (making it generic) |
| 203 | + #[derive(Default)] |
| 204 | + struct PrefixInfo { |
| 205 | + tools: BTreeSet<String>, |
| 206 | + has_generic: bool, // true if any rule in this prefix has no tool specified |
| 207 | + } |
| 208 | + |
| 209 | + let mut prefix_info: BTreeMap<String, PrefixInfo> = BTreeMap::new(); |
| 210 | + |
| 211 | + for rule in rules_array { |
| 212 | + let id = rule["id"].as_str().unwrap_or(""); |
| 213 | + let tool = rule |
| 214 | + .get("evidence") |
| 215 | + .and_then(|e| e.get("applies_to")) |
| 216 | + .and_then(|a| a.get("tool")) |
| 217 | + .and_then(|t| t.as_str()); |
| 218 | + |
| 219 | + if let Some(prefix) = extract_rule_prefix(id) { |
| 220 | + let info = prefix_info.entry(prefix).or_default(); |
| 221 | + if let Some(tool_name) = tool { |
| 222 | + if !tool_name.is_empty() { |
| 223 | + info.tools.insert(tool_name.to_string()); |
| 224 | + } else { |
| 225 | + info.has_generic = true; |
| 226 | + } |
| 227 | + } else { |
| 228 | + // No tool specified = generic rule, applies to all tools |
| 229 | + info.has_generic = true; |
| 230 | + } |
| 231 | + } |
| 232 | + } |
| 233 | + |
| 234 | + // Only include prefixes that: |
| 235 | + // 1. Have exactly one tool specified AND |
| 236 | + // 2. Have NO generic rules (all rules specify that tool) |
| 237 | + generated_code.push_str("/// Mapping of rule ID prefixes to their associated tools.\n"); |
| 238 | + generated_code.push_str("/// \n"); |
| 239 | + generated_code.push_str( |
| 240 | + "/// Derived from rules.json: for each prefix, this is the tool that all rules\n", |
| 241 | + ); |
| 242 | + generated_code |
| 243 | + .push_str("/// with that prefix apply to. Only includes prefixes where ALL rules\n"); |
| 244 | + generated_code |
| 245 | + .push_str("/// consistently specify the same tool (excludes generic prefixes).\n"); |
| 246 | + generated_code.push_str("pub const TOOL_RULE_PREFIXES: &[(&str, &str)] = &[\n"); |
| 247 | + |
| 248 | + for (prefix, info) in &prefix_info { |
| 249 | + // Only include if: exactly one tool AND no generic rules |
| 250 | + if info.tools.len() == 1 && !info.has_generic { |
| 251 | + let tool = info.tools.iter().next().unwrap(); |
| 252 | + generated_code.push_str(&format!( |
| 253 | + " (\"{}\", \"{}\"),\n", |
| 254 | + escape_str(prefix), |
| 255 | + escape_str(tool) |
| 256 | + )); |
| 257 | + } |
| 258 | + } |
159 | 259 | generated_code.push_str("];\n"); |
160 | 260 |
|
161 | 261 | // Write to OUT_DIR |
162 | 262 | let out_dir = env::var("OUT_DIR").unwrap(); |
163 | 263 | let dest_path = Path::new(&out_dir).join("rules_data.rs"); |
164 | 264 | fs::write(&dest_path, generated_code).expect("Failed to write generated rules"); |
165 | 265 | } |
| 266 | + |
| 267 | +/// Extract the rule prefix from a rule ID. |
| 268 | +/// |
| 269 | +/// Examples: |
| 270 | +/// - "CC-HK-001" -> "CC-HK-" |
| 271 | +/// - "COP-001" -> "COP-" |
| 272 | +/// - "AS-001" -> "AS-" |
| 273 | +fn extract_rule_prefix(rule_id: &str) -> Option<String> { |
| 274 | + // Find the last hyphen. If it exists and is followed by only digits, |
| 275 | + // we've found our prefix. This is more efficient than splitting into a vector |
| 276 | + // and correctly handles edge cases like trailing hyphens. |
| 277 | + if let Some((prefix, suffix)) = rule_id.rsplit_once('-') { |
| 278 | + if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) { |
| 279 | + return Some(format!("{}-", prefix)); |
| 280 | + } |
| 281 | + } |
| 282 | + None |
| 283 | +} |
0 commit comments