use std::collections::HashMap;
use crate::models::{AutomationAction, AutomationRule, Event, EventType};
use crate::storage::MetadataStore;
#[derive(Debug)]
pub enum AutomationResult {
Success(String),
Failure(String),
Skipped(String),
}
#[derive(Debug, Clone)]
pub struct AutomationContext {
vars: HashMap<String, String>,
}
impl AutomationContext {
pub fn new(event_type: &str) -> Self {
let mut vars = HashMap::new();
vars.insert("event".to_string(), event_type.to_string());
Self { vars }
}
pub fn set(&mut self, key: &str, value: &str) -> &mut Self {
self.vars.insert(key.to_string(), value.to_string());
self
}
}
fn matching_rules<'a>(
rules: &'a [AutomationRule],
event_type: &EventType,
) -> Vec<&'a AutomationRule> {
rules
.iter()
.filter(|r| r.enabled && r.on == *event_type)
.collect()
}
fn shell_escape(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
fn expand_template(template: &str, vars: &HashMap<String, String>) -> String {
let mut result = template.to_string();
for (key, value) in vars {
result = result.replace(&format!("{{{{{}}}}}", key), &shell_escape(value));
}
result
}
pub fn has_explicit_rule(rules: &[AutomationRule], event_type: &EventType) -> bool {
rules.iter().any(|r| r.enabled && r.on == *event_type)
}
fn execute_shell(rule: &AutomationRule, auto_ctx: &AutomationContext) -> AutomationResult {
let template = match &rule.command {
Some(cmd) => cmd,
None => {
return AutomationResult::Failure("Shell action requires a 'command' field".to_string())
}
};
let expanded = expand_template(template, &auto_ctx.vars);
match std::process::Command::new("sh")
.arg("-c")
.arg(&expanded)
.status()
{
Ok(status) if status.success() => AutomationResult::Success(format!("shell: {}", expanded)),
Ok(status) => AutomationResult::Failure(format!(
"shell exited {}: {}",
status.code().unwrap_or(-1),
expanded
)),
Err(e) => AutomationResult::Failure(format!("shell failed: {}", e)),
}
}
pub fn run(store: &MetadataStore, event: &Event, entity_id: &str) {
let config = match store.load_config() {
Ok(c) => c,
Err(e) => {
eprintln!("Warning: automation disabled (config error: {})", e);
return;
}
};
if config.automation.is_empty() {
return;
}
let event_str = event.event_type.to_string();
let mut auto_ctx = AutomationContext::new(&event_str);
auto_ctx.set("id", &event.entity);
auto_ctx.set("user", &event.by);
if let Some(ref r) = event.rationale {
auto_ctx.set("rationale", r);
}
populate_entity_vars(store, &event.event_type, entity_id, &mut auto_ctx);
for rule in matching_rules(&config.automation, &event.event_type) {
let result = match rule.action {
AutomationAction::Shell => execute_shell(rule, &auto_ctx),
_ => execute_builtin(store, rule, entity_id),
};
match result {
AutomationResult::Success(msg) => println!(" (auto: {})", msg),
AutomationResult::Failure(msg) => {
eprintln!(" Warning: automation '{:?}' failed: {}", rule.action, msg)
}
AutomationResult::Skipped(_) => {}
}
}
}
fn populate_entity_vars(
store: &MetadataStore,
event_type: &EventType,
entity_id: &str,
auto_ctx: &mut AutomationContext,
) {
match event_type {
EventType::ProblemCreated
| EventType::ProblemSolved
| EventType::ProblemDissolved
| EventType::ProblemReopened => {
if let Ok(problem) = store.load_problem(entity_id) {
auto_ctx.set("title", &problem.title);
auto_ctx.set("type", "problem");
if let Some(n) = problem.github_issue {
auto_ctx.set("issue_number", &n.to_string());
}
}
}
EventType::SolutionCreated
| EventType::SolutionSubmitted
| EventType::SolutionApproved
| EventType::SolutionWithdrawn => {
if let Ok(solution) = store.load_solution(entity_id) {
auto_ctx.set("title", &solution.title);
auto_ctx.set("type", "solution");
if let Some(n) = solution.github_pr {
auto_ctx.set("pr_number", &n.to_string());
}
if let Ok(problem) = store.load_problem(&solution.problem_id) {
auto_ctx.set("problem.title", &problem.title);
if let Some(n) = problem.github_issue {
auto_ctx.set("issue_number", &n.to_string());
}
}
}
}
EventType::CritiqueRaised
| EventType::CritiqueAddressed
| EventType::CritiqueDismissed
| EventType::CritiqueValidated
| EventType::CritiqueReplied => {
if let Ok(critique) = store.load_critique(entity_id) {
auto_ctx.set("title", &critique.title);
auto_ctx.set("type", "critique");
if let Ok(solution) = store.load_solution(&critique.solution_id) {
auto_ctx.set("solution.title", &solution.title);
if let Some(n) = solution.github_pr {
auto_ctx.set("pr_number", &n.to_string());
}
if let Ok(problem) = store.load_problem(&solution.problem_id) {
auto_ctx.set("problem.title", &problem.title);
}
}
}
}
EventType::MilestoneCreated
| EventType::MilestoneCompleted
| EventType::GithubIssueCreated
| EventType::GithubIssueImported
| EventType::GithubIssueClosed
| EventType::GithubPrCreated
| EventType::GithubPrMerged
| EventType::GithubReviewImported => {}
}
}
fn execute_builtin(
store: &MetadataStore,
rule: &AutomationRule,
entity_id: &str,
) -> AutomationResult {
use crate::sync::hooks;
match rule.action {
AutomationAction::GithubIssue => {
let mut problem = match store.load_problem(entity_id) {
Ok(p) => p,
Err(e) => return AutomationResult::Failure(e.to_string()),
};
match hooks::do_create_issue(store, &mut problem) {
Ok(()) => AutomationResult::Success("created GitHub issue".to_string()),
Err(e) => AutomationResult::Failure(e.to_string()),
}
}
AutomationAction::GithubClose => {
let problem = match store.load_problem(entity_id) {
Ok(p) => p,
Err(e) => return AutomationResult::Failure(e.to_string()),
};
match hooks::do_close_issue(store, &problem) {
Ok(()) => AutomationResult::Success("closed GitHub issue".to_string()),
Err(e) => AutomationResult::Failure(e.to_string()),
}
}
AutomationAction::GithubPr => {
let mut solution = match store.load_solution(entity_id) {
Ok(s) => s,
Err(e) => return AutomationResult::Failure(e.to_string()),
};
match hooks::do_create_or_update_pr(store, &mut solution) {
Ok(()) => AutomationResult::Success("created/updated GitHub PR".to_string()),
Err(e) => AutomationResult::Failure(e.to_string()),
}
}
AutomationAction::GithubMerge => {
let solution = match store.load_solution(entity_id) {
Ok(s) => s,
Err(e) => return AutomationResult::Failure(e.to_string()),
};
match hooks::do_merge_pr(store, &solution) {
Ok(()) => AutomationResult::Success("merged GitHub PR".to_string()),
Err(e) => AutomationResult::Failure(e.to_string()),
}
}
AutomationAction::GithubSync => {
AutomationResult::Skipped("github_sync not yet implemented as automation".to_string())
}
AutomationAction::Shell => {
execute_shell(rule, &AutomationContext::new(""))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{AutomationAction, AutomationRule, EventType};
fn execute_rule(rule: &AutomationRule, auto_ctx: &AutomationContext) -> AutomationResult {
if rule.action == AutomationAction::Shell {
return execute_shell(rule, auto_ctx);
}
if rule.action != AutomationAction::Shell {
return AutomationResult::Skipped(format!(
"{:?} requires CommandContext (use run() instead)",
rule.action
));
}
unreachable!()
}
fn rule(on: EventType, action: AutomationAction) -> AutomationRule {
AutomationRule {
on,
action,
command: None,
enabled: true,
}
}
#[test]
fn test_matching_rules_filters_by_event() {
let rules = vec![
rule(EventType::SolutionSubmitted, AutomationAction::GithubPr),
rule(EventType::ProblemSolved, AutomationAction::GithubClose),
rule(EventType::SolutionSubmitted, AutomationAction::Shell),
];
let matched = matching_rules(&rules, &EventType::SolutionSubmitted);
assert_eq!(matched.len(), 2);
assert_eq!(matched[0].action, AutomationAction::GithubPr);
assert_eq!(matched[1].action, AutomationAction::Shell);
}
#[test]
fn test_matching_rules_skips_disabled() {
let mut r = rule(EventType::SolutionSubmitted, AutomationAction::GithubPr);
r.enabled = false;
let rules = vec![r];
let matched = matching_rules(&rules, &EventType::SolutionSubmitted);
assert!(matched.is_empty());
}
#[test]
fn test_matching_rules_no_match() {
let rules = vec![rule(
EventType::ProblemSolved,
AutomationAction::GithubClose,
)];
let matched = matching_rules(&rules, &EventType::SolutionSubmitted);
assert!(matched.is_empty());
}
#[test]
fn test_expand_template_simple() {
let mut vars = HashMap::new();
vars.insert("id".to_string(), "abc123".to_string());
vars.insert("title".to_string(), "Fix auth bug".to_string());
let result = expand_template("New: {{title}} ({{id}})", &vars);
assert_eq!(result, "New: 'Fix auth bug' ('abc123')");
}
#[test]
fn test_expand_template_unknown_var_kept() {
let vars = HashMap::new();
let result = expand_template("Hello {{unknown}}", &vars);
assert_eq!(result, "Hello {{unknown}}");
}
#[test]
fn test_expand_template_no_vars() {
let vars = HashMap::new();
let result = expand_template("plain text", &vars);
assert_eq!(result, "plain text");
}
#[test]
fn test_expand_template_dotted_vars() {
let mut vars = HashMap::new();
vars.insert("problem.title".to_string(), "Auth bug".to_string());
let result = expand_template("On: {{problem.title}}", &vars);
assert_eq!(result, "On: 'Auth bug'");
}
#[test]
fn test_shell_escape_basic() {
assert_eq!(shell_escape("hello"), "'hello'");
}
#[test]
fn test_shell_escape_single_quotes() {
assert_eq!(shell_escape("it's here"), "'it'\\''s here'");
}
#[test]
fn test_shell_escape_injection() {
assert_eq!(shell_escape("'; rm -rf / #"), "''\\''; rm -rf / #'");
}
#[test]
fn test_execute_rule_shell_missing_command_returns_failure() {
let r = AutomationRule {
on: EventType::ProblemCreated,
action: AutomationAction::Shell,
command: None,
enabled: true,
};
let auto_ctx = AutomationContext::new("problem_created");
let result = execute_rule(&r, &auto_ctx);
assert!(matches!(result, AutomationResult::Failure(_)));
}
#[test]
fn test_execute_rule_shell_runs_command() {
let r = AutomationRule {
on: EventType::ProblemCreated,
action: AutomationAction::Shell,
command: Some("true".to_string()),
enabled: true,
};
let auto_ctx = AutomationContext::new("problem_created");
let result = execute_rule(&r, &auto_ctx);
assert!(matches!(result, AutomationResult::Success(_)));
}
#[test]
fn test_execute_rule_shell_expands_vars() {
let r = AutomationRule {
on: EventType::ProblemCreated,
action: AutomationAction::Shell,
command: Some("echo '{{title}}'".to_string()),
enabled: true,
};
let mut auto_ctx = AutomationContext::new("problem_created");
auto_ctx.set("title", "My Problem");
let result = execute_rule(&r, &auto_ctx);
assert!(matches!(result, AutomationResult::Success(_)));
}
#[test]
fn test_execute_rule_builtin_without_ctx_returns_skipped() {
let r = rule(EventType::ProblemCreated, AutomationAction::GithubIssue);
let auto_ctx = AutomationContext::new("problem_created");
let result = execute_rule(&r, &auto_ctx);
assert!(matches!(result, AutomationResult::Skipped(_)));
}
#[test]
fn test_has_explicit_rule_for_event() {
let rules = vec![
rule(EventType::SolutionSubmitted, AutomationAction::GithubPr),
rule(EventType::ProblemSolved, AutomationAction::GithubClose),
];
assert!(has_explicit_rule(&rules, &EventType::SolutionSubmitted));
assert!(has_explicit_rule(&rules, &EventType::ProblemSolved));
assert!(!has_explicit_rule(&rules, &EventType::ProblemCreated));
}
}