use crate::filter::expr::{self, Expr};
use regex::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tracing::{instrument, warn};
#[derive(Debug, Clone)]
pub struct RegionDefinition {
pub name: String,
pub description: Option<String>,
pub start_points: Vec<CompiledMatchPoint>,
pub end_points: Vec<CompiledMatchPoint>,
pub correlate: Vec<String>,
pub name_template: String,
pub description_template: Option<String>,
pub timeout: Option<Duration>,
pub timeout_reason: Option<String>,
}
#[derive(Debug, Clone)]
pub struct CompiledMatchPoint {
pub filter: Expr,
pub filter_str: String,
pub regex: Option<Regex>,
pub reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RegionConfigFile {
pub regions: Vec<RawRegionDef>,
}
#[derive(Debug, Deserialize)]
pub struct RawRegionDef {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub start_points: Vec<RawMatchPoint>,
pub end_points: Vec<RawMatchPoint>,
pub correlate: Vec<String>,
pub template: RawTemplate,
#[serde(default)]
pub timeout: Option<String>,
#[serde(default)]
pub timeout_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RawMatchPoint {
pub filter: String,
#[serde(default)]
pub regex: Option<String>,
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct RawTemplate {
pub name: String,
#[serde(default)]
pub description: Option<String>,
}
pub(crate) fn parse_timeout(s: &str) -> Result<Duration, String> {
let s = s.trim();
if s.is_empty() {
return Err("empty timeout".into());
}
let (num_str, unit) = if let Some(n) = s.strip_suffix('s') {
(n, 1u64)
} else if let Some(n) = s.strip_suffix('m') {
(n, 60)
} else if let Some(n) = s.strip_suffix('h') {
(n, 3600)
} else {
return Err(format!("invalid timeout unit in '{}' (use s/m/h)", s));
};
let num: u64 = num_str
.trim()
.parse()
.map_err(|_| format!("invalid timeout number in '{}'", s))?;
Ok(Duration::from_secs(num * unit))
}
fn compile_match_point(raw: &RawMatchPoint) -> Result<CompiledMatchPoint, String> {
let filter = expr::parse(&raw.filter).map_err(|e| format!("filter '{}': {}", raw.filter, e))?;
let regex = match &raw.regex {
Some(pattern) => {
let re = Regex::new(pattern).map_err(|e| format!("regex '{}': {}", pattern, e))?;
Some(re)
}
None => None,
};
Ok(CompiledMatchPoint {
filter,
filter_str: raw.filter.clone(),
regex,
reason: raw.reason.clone(),
})
}
fn compile_definition(raw: &RawRegionDef) -> Result<RegionDefinition, String> {
let start_points: Vec<CompiledMatchPoint> = raw
.start_points
.iter()
.map(compile_match_point)
.collect::<Result<_, _>>()
.map_err(|e| format!("region '{}' start_point: {}", raw.name, e))?;
let end_points: Vec<CompiledMatchPoint> = raw
.end_points
.iter()
.map(compile_match_point)
.collect::<Result<_, _>>()
.map_err(|e| format!("region '{}' end_point: {}", raw.name, e))?;
let timeout = match &raw.timeout {
Some(s) => {
Some(parse_timeout(s).map_err(|e| format!("region '{}' timeout: {}", raw.name, e))?)
}
None => None,
};
Ok(RegionDefinition {
name: raw.name.clone(),
description: raw.description.clone(),
start_points,
end_points,
correlate: raw.correlate.clone(),
name_template: raw.template.name.clone(),
description_template: raw.template.description.clone(),
timeout,
timeout_reason: raw.timeout_reason.clone(),
})
}
pub fn load_from_str(yaml: &str) -> Result<Vec<RegionDefinition>, String> {
let config: RegionConfigFile =
serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {}", e))?;
config.regions.iter().map(compile_definition).collect()
}
#[instrument(skip(path), fields(path = %path.display()))]
pub fn load_from_file(path: &Path) -> Result<Vec<RegionDefinition>, String> {
let content =
std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path.display(), e))?;
load_from_str(&content).map_err(|e| format!("{}: {}", path.display(), e))
}
pub fn load_from_dir(dir: &Path) -> Result<Vec<RegionDefinition>, String> {
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut defs = Vec::new();
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)
.map_err(|e| format!("{}: {}", dir.display(), e))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.extension()
.map(|ext| ext == "yaml" || ext == "yml")
.unwrap_or(false)
})
.collect();
entries.sort();
for path in &entries {
match load_from_file(path) {
Ok(mut file_defs) => defs.append(&mut file_defs),
Err(e) => {
eprintln!("Warning: skipping region config {}: {}", path.display(), e);
}
}
}
Ok(defs)
}
#[instrument]
pub fn load_all() -> Vec<RegionDefinition> {
let mut defs = Vec::new();
let dirs = [
PathBuf::from("/etc/scouty/regions"),
dirs::home_dir()
.map(|h| h.join(".scouty/regions"))
.unwrap_or_default(),
PathBuf::from("./scouty-regions"),
];
for dir in &dirs {
if dir.as_os_str().is_empty() {
continue;
}
if let Ok(mut d) = load_from_dir(dir) {
defs.append(&mut d);
}
}
defs
}
pub fn render_template(template: &str, metadata: &HashMap<String, String>) -> String {
let mut result = template.to_string();
for (key, value) in metadata {
result = result.replace(&format!("{{{}}}", key), value);
}
result
}