use forjar::core::rules_runtime::{evaluate_event, fired_actions, matching_rulebooks};
use forjar::core::state::rulebook_log::{append_entry, make_entry, read_entries};
use forjar::core::types::{
event_matches_pattern, event_matches_rulebook, ApplyAction, CooldownTracker, EventPattern,
EventType, InfraEvent, NotifyAction, Rulebook, RulebookAction, RulebookConfig,
};
use std::collections::HashMap;
use tempfile::TempDir;
fn make_event(event_type: EventType, payload: HashMap<String, String>) -> InfraEvent {
InfraEvent {
event_type,
timestamp: "2026-03-09T12:00:00Z".into(),
machine: Some("web-01".into()),
payload,
}
}
fn file_changed_event(path: &str) -> InfraEvent {
let mut payload = HashMap::new();
payload.insert("path".into(), path.into());
make_event(EventType::FileChanged, payload)
}
fn config_repair_rulebook() -> Rulebook {
let mut match_fields = HashMap::new();
match_fields.insert("path".into(), "/etc/nginx/nginx.conf".into());
Rulebook {
name: "config-repair".into(),
description: Some("Repair nginx config when changed".into()),
events: vec![EventPattern {
event_type: EventType::FileChanged,
match_fields,
}],
conditions: Vec::new(),
actions: vec![RulebookAction {
apply: Some(ApplyAction {
file: "forjar.yaml".into(),
subset: vec!["nginx-conf".into()],
tags: vec!["config".into()],
machine: Some("web-01".into()),
}),
destroy: None,
script: None,
notify: None,
}],
cooldown_secs: 30,
max_retries: 3,
enabled: true,
}
}
fn alert_rulebook() -> Rulebook {
Rulebook {
name: "alert-on-metric".into(),
description: Some("Alert when metric threshold exceeded".into()),
events: vec![EventPattern {
event_type: EventType::MetricThreshold,
match_fields: HashMap::new(),
}],
conditions: Vec::new(),
actions: vec![RulebookAction {
apply: None,
destroy: None,
script: None,
notify: Some(NotifyAction {
channel: "https://hooks.slack.com/services/xxx".into(),
message: "CPU threshold exceeded on {{machine}}".into(),
}),
}],
cooldown_secs: 300,
max_retries: 1,
enabled: true,
}
}
#[test]
fn file_change_triggers_config_repair() {
let event = file_changed_event("/etc/nginx/nginx.conf");
let rb = config_repair_rulebook();
assert!(event_matches_rulebook(&event, &rb));
let config = RulebookConfig {
rulebooks: vec![rb],
};
let mut tracker = CooldownTracker::default();
let results = evaluate_event(&event, &config, &mut tracker);
assert_eq!(results.len(), 1);
assert!(!results[0].cooldown_blocked);
assert_eq!(results[0].actions.len(), 1);
assert_eq!(results[0].actions[0].action_type(), "apply");
}
#[test]
fn wrong_path_no_trigger() {
let event = file_changed_event("/etc/hosts");
let rb = config_repair_rulebook();
assert!(!event_matches_rulebook(&event, &rb));
}
#[test]
fn cooldown_prevents_rapid_refire() {
let event = file_changed_event("/etc/nginx/nginx.conf");
let config = RulebookConfig {
rulebooks: vec![config_repair_rulebook()],
};
let mut tracker = CooldownTracker::default();
let r1 = fired_actions(&event, &config, &mut tracker);
assert_eq!(r1.len(), 1);
let r2 = fired_actions(&event, &config, &mut tracker);
assert!(r2.is_empty());
}
#[test]
fn full_pipeline_event_to_log() {
let dir = TempDir::new().unwrap();
let event = file_changed_event("/etc/nginx/nginx.conf");
let config = RulebookConfig {
rulebooks: vec![config_repair_rulebook()],
};
let mut tracker = CooldownTracker::default();
let actions = fired_actions(&event, &config, &mut tracker);
assert_eq!(actions.len(), 1);
let (rb_name, action_list) = &actions[0];
let entry = make_entry(&event, rb_name, action_list[0].action_type(), true, None);
append_entry(dir.path(), &entry).unwrap();
let entries = read_entries(dir.path()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].rulebook, "config-repair");
assert_eq!(entries[0].action_type, "apply");
assert!(entries[0].success);
assert_eq!(entries[0].event_type, EventType::FileChanged);
}
#[test]
fn multiple_rulebooks_single_event() {
let event = file_changed_event("/etc/nginx/nginx.conf");
let mut backup_rb = config_repair_rulebook();
backup_rb.name = "backup-trigger".into();
backup_rb.events[0].match_fields.clear();
let config = RulebookConfig {
rulebooks: vec![config_repair_rulebook(), backup_rb],
};
let mut tracker = CooldownTracker::default();
let results = evaluate_event(&event, &config, &mut tracker);
assert_eq!(results.len(), 2);
}
#[test]
fn matching_ignores_cooldown() {
let event = file_changed_event("/etc/nginx/nginx.conf");
let config = RulebookConfig {
rulebooks: vec![config_repair_rulebook()],
};
let matched = matching_rulebooks(&event, &config);
assert_eq!(matched.len(), 1);
assert_eq!(matched[0].name, "config-repair");
}
#[test]
fn disabled_rulebook_no_fire() {
let mut rb = config_repair_rulebook();
rb.enabled = false;
let event = file_changed_event("/etc/nginx/nginx.conf");
let config = RulebookConfig {
rulebooks: vec![rb],
};
let mut tracker = CooldownTracker::default();
let actions = fired_actions(&event, &config, &mut tracker);
assert!(actions.is_empty());
}
#[test]
fn metric_threshold_triggers_alert() {
let event = make_event(EventType::MetricThreshold, HashMap::new());
let config = RulebookConfig {
rulebooks: vec![alert_rulebook()],
};
let mut tracker = CooldownTracker::default();
let results = evaluate_event(&event, &config, &mut tracker);
assert_eq!(results.len(), 1);
assert_eq!(results[0].actions[0].action_type(), "notify");
}
#[test]
fn manual_trigger_event() {
let event = make_event(EventType::Manual, HashMap::new());
let rb = Rulebook {
name: "manual-deploy".into(),
description: None,
events: vec![EventPattern {
event_type: EventType::Manual,
match_fields: HashMap::new(),
}],
conditions: Vec::new(),
actions: vec![RulebookAction {
apply: Some(ApplyAction {
file: "forjar.yaml".into(),
subset: Vec::new(),
tags: Vec::new(),
machine: None,
}),
destroy: None,
script: None,
notify: None,
}],
cooldown_secs: 0,
max_retries: 0,
enabled: true,
};
let config = RulebookConfig {
rulebooks: vec![rb],
};
let mut tracker = CooldownTracker::default();
let actions = fired_actions(&event, &config, &mut tracker);
assert_eq!(actions.len(), 1);
}
#[test]
fn log_ordering_preserved() {
let dir = TempDir::new().unwrap();
let event = file_changed_event("/etc/nginx/nginx.conf");
for i in 0..5 {
let entry = make_entry(&event, &format!("rulebook-{i}"), "apply", true, None);
append_entry(dir.path(), &entry).unwrap();
}
let entries = read_entries(dir.path()).unwrap();
assert_eq!(entries.len(), 5);
for (i, entry) in entries.iter().enumerate() {
assert_eq!(entry.rulebook, format!("rulebook-{i}"));
}
}
#[test]
fn failed_action_logged() {
let dir = TempDir::new().unwrap();
let event = file_changed_event("/etc/nginx/nginx.conf");
let entry = make_entry(
&event,
"config-repair",
"apply",
false,
Some("resource nginx-conf failed: permission denied".into()),
);
append_entry(dir.path(), &entry).unwrap();
let entries = read_entries(dir.path()).unwrap();
assert!(!entries[0].success);
assert!(entries[0]
.error
.as_ref()
.unwrap()
.contains("permission denied"));
}
#[test]
fn multi_field_pattern_match() {
let mut match_fields = HashMap::new();
match_fields.insert("path".into(), "/etc/nginx/nginx.conf".into());
match_fields.insert("action".into(), "modify".into());
let pattern = EventPattern {
event_type: EventType::FileChanged,
match_fields,
};
let mut payload = HashMap::new();
payload.insert("path".into(), "/etc/nginx/nginx.conf".into());
payload.insert("action".into(), "modify".into());
let event = make_event(EventType::FileChanged, payload);
assert!(event_matches_pattern(&event, &pattern));
let mut partial_payload = HashMap::new();
partial_payload.insert("path".into(), "/etc/nginx/nginx.conf".into());
let partial_event = make_event(EventType::FileChanged, partial_payload);
assert!(!event_matches_pattern(&partial_event, &pattern));
}
#[test]
fn convergence_after_cooldown() {
let rb = Rulebook {
name: "fast-converge".into(),
description: None,
events: vec![EventPattern {
event_type: EventType::FileChanged,
match_fields: HashMap::new(),
}],
conditions: Vec::new(),
actions: vec![RulebookAction {
apply: Some(ApplyAction {
file: "forjar.yaml".into(),
subset: Vec::new(),
tags: Vec::new(),
machine: None,
}),
destroy: None,
script: None,
notify: None,
}],
cooldown_secs: 0, max_retries: 3,
enabled: true,
};
let config = RulebookConfig {
rulebooks: vec![rb],
};
let event = make_event(EventType::FileChanged, HashMap::new());
let mut tracker = CooldownTracker::default();
for _ in 0..5 {
let actions = fired_actions(&event, &config, &mut tracker);
assert_eq!(actions.len(), 1);
}
}