use super::ioc::ioc_feed_to_rules;
use super::schema::{IocFeedFile, Rule, RulePackFile};
use super::{RuleError, RULE_PACK_SCHEMA_VERSION};
use std::path::{Path, PathBuf};
pub fn parse_rules_file(content: &str) -> Result<Vec<Rule>, RuleError> {
let mut errors = Vec::new();
if let Ok(pack) = serde_yaml::from_str::<RulePackFile>(content) {
if !is_supported_rule_pack_schema(&pack.schema_version) {
return Err(RuleError::InvalidRule(format!(
"Unsupported rule pack schema version: {}",
pack.schema_version
)));
}
if !pack.rules.is_empty() {
return Ok(pack.rules);
}
errors.push("RulePackFile format (empty rules)".to_string());
} else if !content.trim().is_empty() {
errors.push("RulePackFile format".to_string());
}
if let Ok(feed) = serde_yaml::from_str::<IocFeedFile>(content) {
if !is_supported_rule_pack_schema(&feed.schema_version) {
return Err(RuleError::InvalidRule(format!(
"Unsupported IOC feed schema version: {}",
feed.schema_version
)));
}
if !(feed.domains.is_empty() && feed.filenames.is_empty() && feed.ips.is_empty()) {
return ioc_feed_to_rules(&feed);
}
} else if !content.trim().is_empty() {
errors.push("IocFeedFile format".to_string());
}
match serde_yaml::from_str::<Vec<Rule>>(content) {
Ok(rules) => {
if !rules.is_empty() {
tracing::warn!(
"rule-parser: accepted bare Vec<Rule> format without schema_version — \
pack authors should use RulePackFile with schema_version {}",
RULE_PACK_SCHEMA_VERSION
);
}
return Ok(rules);
}
Err(e) => {
errors.push(format!("rule list format: {e}"));
}
}
if errors.is_empty() {
Err(RuleError::InvalidRule(
"Rule file is empty or contains no valid rules".to_string(),
))
} else {
Err(RuleError::InvalidRule(format!(
"Failed to parse rules file. Attempted formats: {}",
errors.join("; ")
)))
}
}
pub fn is_supported_rule_pack_schema(schema_version: &str) -> bool {
schema_version == RULE_PACK_SCHEMA_VERSION
}
pub const RULES_DIR_ENV: &str = "SKILL_VEIL_RULES_DIR";
const CURRENT_POINTER_FILENAME: &str = "current.json";
pub fn default_external_rule_dirs() -> Vec<PathBuf> {
let env_value = std::env::var(RULES_DIR_ENV).ok();
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
compose_external_rule_dirs(env_value.as_deref(), current_install_overlay(), &cwd)
}
fn compose_external_rule_dirs(
env_value: Option<&str>,
cache_overlay: Option<PathBuf>,
cwd: &Path,
) -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(raw) = env_value {
for part in raw.split(env_path_separator()) {
let trimmed = part.trim();
if !trimmed.is_empty() {
dirs.push(PathBuf::from(trimmed));
}
}
}
if let Some(overlay) = cache_overlay {
dirs.push(overlay);
}
dirs.push(cwd.join("rules").join("official"));
dirs
}
const fn env_path_separator() -> char {
if cfg!(windows) {
';'
} else {
':'
}
}
fn current_install_overlay() -> Option<PathBuf> {
let install_root = dirs::cache_dir()?.join("skill-veil").join("rules");
let pointer_path = install_root.join(CURRENT_POINTER_FILENAME);
let body = std::fs::read_to_string(&pointer_path).ok()?;
let pointer: serde_json::Value = serde_json::from_str(&body).ok()?;
let version = pointer.get("version")?.as_str()?.to_string();
if version.is_empty() {
return None;
}
let candidate = install_root.join(&version).join("official");
if candidate.is_dir() {
Some(candidate)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_cwd_fallback_is_always_present() {
let dirs = compose_external_rule_dirs(None, None, Path::new("/test/cwd"));
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0], PathBuf::from("/test/cwd/rules/official"));
}
#[test]
fn env_var_paths_appear_first_and_skip_empties() {
let sep = env_path_separator();
let raw = format!("/path/a{sep}{sep}/path/b{sep}");
let dirs = compose_external_rule_dirs(Some(&raw), None, Path::new("/cwd"));
assert_eq!(dirs[0], PathBuf::from("/path/a"));
assert_eq!(dirs[1], PathBuf::from("/path/b"));
assert_eq!(dirs[2], PathBuf::from("/cwd/rules/official"));
assert_eq!(dirs.len(), 3);
}
#[test]
fn cache_overlay_sits_between_env_and_legacy_fallback() {
let dirs = compose_external_rule_dirs(
Some("/from/env"),
Some(PathBuf::from("/cache/v0.1.0/official")),
Path::new("/cwd"),
);
assert_eq!(dirs[0], PathBuf::from("/from/env"));
assert_eq!(dirs[1], PathBuf::from("/cache/v0.1.0/official"));
assert_eq!(dirs[2], PathBuf::from("/cwd/rules/official"));
}
}