use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FlowBinding {
#[serde(default = "default_api_version")]
pub api_version: String,
#[serde(default = "default_binding_kind")]
pub kind: String,
pub metadata: FlowBindingMetadata,
pub spec: FlowBindingSpec,
}
fn default_api_version() -> String {
"aof.dev/v1".to_string()
}
fn default_binding_kind() -> String {
"FlowBinding".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FlowBindingMetadata {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub annotations: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct FlowBindingSpec {
pub trigger: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
pub flow: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fleet: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#match: Option<BindingMatch>,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub config: HashMap<String, serde_json::Value>,
}
fn default_enabled() -> bool {
true
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct BindingMatch {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub patterns: Vec<String>,
#[serde(default)]
pub priority: i32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub users: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required_keywords: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub excluded_keywords: Vec<String>,
}
impl FlowBinding {
pub fn name(&self) -> &str {
&self.metadata.name
}
pub fn trigger_ref(&self) -> &str {
&self.spec.trigger
}
pub fn context_ref(&self) -> Option<&str> {
self.spec.context.as_deref()
}
pub fn flow_ref(&self) -> &str {
&self.spec.flow
}
pub fn agent_ref(&self) -> Option<&str> {
self.spec.agent.as_deref()
}
pub fn fleet_ref(&self) -> Option<&str> {
self.spec.fleet.as_deref()
}
pub fn validate(&self) -> Result<(), String> {
if self.metadata.name.is_empty() {
return Err("FlowBinding name is required".to_string());
}
if self.spec.trigger.is_empty() {
return Err("FlowBinding requires a trigger reference".to_string());
}
if self.spec.flow.is_empty() && self.spec.agent.is_none() && self.spec.fleet.is_none() {
return Err("FlowBinding requires flow, agent, or fleet reference".to_string());
}
Ok(())
}
pub fn matches(&self, channel: Option<&str>, user: Option<&str>, text: Option<&str>) -> bool {
if let Some(ref match_config) = self.spec.r#match {
if !match_config.channels.is_empty() {
if let Some(ch) = channel {
if !match_config.channels.iter().any(|c| c == ch) {
return false;
}
} else {
return false;
}
}
if !match_config.users.is_empty() {
if let Some(u) = user {
if !match_config.users.iter().any(|allowed| allowed == u) {
return false;
}
} else {
return false;
}
}
if !match_config.patterns.is_empty() {
if let Some(t) = text {
let matches_pattern = match_config.patterns.iter().any(|p| {
if let Ok(re) = regex::Regex::new(p) {
re.is_match(t)
} else {
t.to_lowercase().contains(&p.to_lowercase())
}
});
if !matches_pattern {
return false;
}
} else {
return false;
}
}
if !match_config.required_keywords.is_empty() {
if let Some(t) = text {
let text_lower = t.to_lowercase();
if !match_config.required_keywords.iter().all(|kw| text_lower.contains(&kw.to_lowercase())) {
return false;
}
} else {
return false;
}
}
if !match_config.excluded_keywords.is_empty() {
if let Some(t) = text {
let text_lower = t.to_lowercase();
if match_config.excluded_keywords.iter().any(|kw| text_lower.contains(&kw.to_lowercase())) {
return false;
}
}
}
}
true
}
pub fn match_score(&self, channel: Option<&str>, user: Option<&str>, text: Option<&str>) -> i32 {
if !self.matches(channel, user, text) {
return i32::MIN;
}
let mut score = 0i32;
if let Some(ref match_config) = self.spec.r#match {
score += match_config.priority;
if !match_config.channels.is_empty() && channel.is_some() {
score += 100;
}
if !match_config.users.is_empty() && user.is_some() {
score += 80;
}
if !match_config.patterns.is_empty() && text.is_some() {
score += 60;
}
if !match_config.required_keywords.is_empty() {
score += 40 * match_config.required_keywords.len() as i32;
}
}
score += 10;
score
}
}
#[derive(Debug, Clone)]
pub struct ResolvedBinding {
pub binding: FlowBinding,
pub score: i32,
pub trigger_name: String,
pub context_name: Option<String>,
pub flow_name: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_flow_binding() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: prod-k8s-binding
labels:
environment: production
spec:
trigger: slack-prod-channel
context: prod
flow: k8s-ops-flow
match:
patterns:
- kubectl
- k8s
priority: 100
"#;
let binding: FlowBinding = serde_yaml::from_str(yaml).unwrap();
assert_eq!(binding.metadata.name, "prod-k8s-binding");
assert_eq!(binding.spec.trigger, "slack-prod-channel");
assert_eq!(binding.spec.context, Some("prod".to_string()));
assert_eq!(binding.spec.flow, "k8s-ops-flow");
assert!(binding.validate().is_ok());
}
#[test]
fn test_parse_simple_binding() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: simple-binding
spec:
trigger: telegram-oncall
agent: incident-responder
flow: incident-flow
"#;
let binding: FlowBinding = serde_yaml::from_str(yaml).unwrap();
assert_eq!(binding.spec.agent, Some("incident-responder".to_string()));
assert!(binding.validate().is_ok());
}
#[test]
fn test_parse_binding_with_match() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: k8s-only-binding
spec:
trigger: slack-prod
context: prod
flow: k8s-ops-flow
match:
patterns:
- "^kubectl"
- "^k8s"
channels:
- production
required_keywords:
- pod
excluded_keywords:
- delete
priority: 200
"#;
let binding: FlowBinding = serde_yaml::from_str(yaml).unwrap();
let match_config = binding.spec.r#match.as_ref().unwrap();
assert_eq!(match_config.patterns.len(), 2);
assert_eq!(match_config.channels.len(), 1);
assert_eq!(match_config.required_keywords.len(), 1);
assert_eq!(match_config.excluded_keywords.len(), 1);
assert_eq!(match_config.priority, 200);
}
#[test]
fn test_validation_errors() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: ""
spec:
trigger: test
flow: test
"#;
let binding: FlowBinding = serde_yaml::from_str(yaml).unwrap();
assert!(binding.validate().is_err());
let yaml2 = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: test
spec:
trigger: ""
flow: test
"#;
let binding2: FlowBinding = serde_yaml::from_str(yaml2).unwrap();
assert!(binding2.validate().is_err());
let yaml3 = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: test
spec:
trigger: test
flow: ""
"#;
let binding3: FlowBinding = serde_yaml::from_str(yaml3).unwrap();
assert!(binding3.validate().is_err());
}
#[test]
fn test_matches() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: test
spec:
trigger: test
flow: test-flow
match:
patterns:
- kubectl
channels:
- production
required_keywords:
- pod
excluded_keywords:
- delete
"#;
let binding: FlowBinding = serde_yaml::from_str(yaml).unwrap();
assert!(binding.matches(Some("production"), None, Some("kubectl get pod")));
assert!(!binding.matches(Some("staging"), None, Some("kubectl get pod")));
assert!(!binding.matches(Some("production"), None, Some("kubectl get deployment")));
assert!(!binding.matches(Some("production"), None, Some("kubectl delete pod")));
}
#[test]
fn test_match_score() {
let yaml1 = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: specific
spec:
trigger: test
flow: test
match:
patterns: [kubectl]
channels: [production]
priority: 50
"#;
let yaml2 = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: catchall
spec:
trigger: test
flow: test
"#;
let specific: FlowBinding = serde_yaml::from_str(yaml1).unwrap();
let catchall: FlowBinding = serde_yaml::from_str(yaml2).unwrap();
let score1 = specific.match_score(Some("production"), None, Some("kubectl get pods"));
let score2 = catchall.match_score(Some("production"), None, Some("kubectl get pods"));
assert!(score1 > score2);
}
#[test]
fn test_disabled_binding() {
let yaml = r#"
apiVersion: aof.dev/v1
kind: FlowBinding
metadata:
name: disabled
spec:
trigger: test
flow: test
enabled: false
"#;
let binding: FlowBinding = serde_yaml::from_str(yaml).unwrap();
assert!(!binding.spec.enabled);
}
}