Skip to main content

scud_weave/
event.rs

1//! Event model for weave coordination.
2
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5
6use crate::matcher::GlobPattern;
7
8/// A coordination event representing an action by an agent.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub struct Event {
11    pub kind: EventKind,
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub agent: Option<String>,
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub target: Option<String>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub task_id: Option<String>,
18    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
19    pub metadata: HashMap<String, String>,
20}
21
22/// The kind of event being coordinated.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
24pub enum EventKind {
25    FileWrite,
26    FileCreate,
27    DependencyAdd,
28    DependencyRemove,
29    SchemaChange,
30    ApiChange,
31    ConfigChange,
32    TestRun,
33    TestPass,
34    TestFail,
35    LintPass,
36    LintFail,
37    Commit,
38    Build,
39    TaskClaim,
40    TaskComplete,
41    DangerousCommand,
42    Custom(String),
43}
44
45impl EventKind {
46    /// Parse an event kind from a string.
47    pub fn parse(s: &str) -> Self {
48        match s {
49            "FileWrite" => EventKind::FileWrite,
50            "FileCreate" => EventKind::FileCreate,
51            "DependencyAdd" => EventKind::DependencyAdd,
52            "DependencyRemove" => EventKind::DependencyRemove,
53            "SchemaChange" => EventKind::SchemaChange,
54            "ApiChange" => EventKind::ApiChange,
55            "ConfigChange" => EventKind::ConfigChange,
56            "TestRun" => EventKind::TestRun,
57            "TestPass" => EventKind::TestPass,
58            "TestFail" => EventKind::TestFail,
59            "LintPass" => EventKind::LintPass,
60            "LintFail" => EventKind::LintFail,
61            "Commit" => EventKind::Commit,
62            "Build" => EventKind::Build,
63            "TaskClaim" => EventKind::TaskClaim,
64            "TaskComplete" => EventKind::TaskComplete,
65            "DangerousCommand" => EventKind::DangerousCommand,
66            other => EventKind::Custom(other.to_string()),
67        }
68    }
69
70    /// Convert to string representation.
71    pub fn as_str(&self) -> &str {
72        match self {
73            EventKind::FileWrite => "FileWrite",
74            EventKind::FileCreate => "FileCreate",
75            EventKind::DependencyAdd => "DependencyAdd",
76            EventKind::DependencyRemove => "DependencyRemove",
77            EventKind::SchemaChange => "SchemaChange",
78            EventKind::ApiChange => "ApiChange",
79            EventKind::ConfigChange => "ConfigChange",
80            EventKind::TestRun => "TestRun",
81            EventKind::TestPass => "TestPass",
82            EventKind::TestFail => "TestFail",
83            EventKind::LintPass => "LintPass",
84            EventKind::LintFail => "LintFail",
85            EventKind::Commit => "Commit",
86            EventKind::Build => "Build",
87            EventKind::TaskClaim => "TaskClaim",
88            EventKind::TaskComplete => "TaskComplete",
89            EventKind::DangerousCommand => "DangerousCommand",
90            EventKind::Custom(s) => s.as_str(),
91        }
92    }
93}
94
95impl std::fmt::Display for EventKind {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// A pattern for matching events in b-thread rules.
102#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
103pub struct EventPattern {
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub kind: Option<EventKind>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub agent: Option<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub target: Option<GlobPattern>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub task_id: Option<String>,
112    /// Match any agent EXCEPT this one.
113    #[serde(default)]
114    pub negate_agent: bool,
115    /// Match any target NOT in this list.
116    #[serde(default, skip_serializing_if = "Vec::is_empty")]
117    pub target_not: Vec<GlobPattern>,
118}
119
120impl EventPattern {
121    /// Create a pattern matching a specific event kind.
122    pub fn kind(kind: EventKind) -> Self {
123        EventPattern {
124            kind: Some(kind),
125            agent: None,
126            target: None,
127            task_id: None,
128            negate_agent: false,
129            target_not: Vec::new(),
130        }
131    }
132
133    /// Check if an event matches this pattern.
134    pub fn matches_event(&self, event: &Event) -> bool {
135        // Check kind
136        if let Some(ref pk) = self.kind {
137            if pk != &event.kind {
138                return false;
139            }
140        }
141
142        // Check agent (with optional negation)
143        if let Some(ref pa) = self.agent {
144            match &event.agent {
145                Some(ea) => {
146                    if self.negate_agent {
147                        if ea == pa {
148                            return false;
149                        }
150                    } else if ea != pa {
151                        return false;
152                    }
153                }
154                None => {
155                    // No agent on event: negated match succeeds, normal match fails
156                    if !self.negate_agent {
157                        return false;
158                    }
159                }
160            }
161        }
162
163        // Check target glob
164        if let Some(ref pt) = self.target {
165            match &event.target {
166                Some(et) => {
167                    if !pt.matches(et) {
168                        return false;
169                    }
170                }
171                None => return false,
172            }
173        }
174
175        // Check target_not exclusions
176        if !self.target_not.is_empty() {
177            if let Some(ref et) = event.target {
178                for exclude in &self.target_not {
179                    if exclude.matches(et) {
180                        return false;
181                    }
182                }
183            }
184        }
185
186        // Check task_id
187        if let Some(ref pt) = self.task_id {
188            match &event.task_id {
189                Some(et) => {
190                    if et != pt {
191                        return false;
192                    }
193                }
194                None => return false,
195            }
196        }
197
198        true
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_event_kind_parse_roundtrip() {
208        let kinds = [
209            "FileWrite",
210            "TestPass",
211            "Commit",
212            "DangerousCommand",
213            "CustomThing",
214        ];
215        for kind_str in kinds {
216            let kind = EventKind::parse(kind_str);
217            assert_eq!(kind.as_str(), kind_str);
218        }
219    }
220
221    #[test]
222    fn test_event_pattern_kind_only() {
223        let pattern = EventPattern::kind(EventKind::FileWrite);
224        let event = Event {
225            kind: EventKind::FileWrite,
226            agent: Some("agent-1".to_string()),
227            target: Some("src/main.rs".to_string()),
228            task_id: None,
229            metadata: HashMap::new(),
230        };
231        assert!(pattern.matches_event(&event));
232
233        let wrong_kind = Event {
234            kind: EventKind::Commit,
235            agent: Some("agent-1".to_string()),
236            target: None,
237            task_id: None,
238            metadata: HashMap::new(),
239        };
240        assert!(!pattern.matches_event(&wrong_kind));
241    }
242
243    #[test]
244    fn test_event_pattern_kind_and_agent() {
245        let pattern = EventPattern {
246            kind: Some(EventKind::FileWrite),
247            agent: Some("agent-1".to_string()),
248            target: None,
249            task_id: None,
250            negate_agent: false,
251            target_not: Vec::new(),
252        };
253
254        let matching = Event {
255            kind: EventKind::FileWrite,
256            agent: Some("agent-1".to_string()),
257            target: Some("x".to_string()),
258            task_id: None,
259            metadata: HashMap::new(),
260        };
261        assert!(pattern.matches_event(&matching));
262
263        let wrong_agent = Event {
264            kind: EventKind::FileWrite,
265            agent: Some("agent-2".to_string()),
266            target: None,
267            task_id: None,
268            metadata: HashMap::new(),
269        };
270        assert!(!pattern.matches_event(&wrong_agent));
271
272        let no_agent = Event {
273            kind: EventKind::FileWrite,
274            agent: None,
275            target: None,
276            task_id: None,
277            metadata: HashMap::new(),
278        };
279        assert!(!pattern.matches_event(&no_agent));
280    }
281
282    #[test]
283    fn test_event_pattern_negated_agent() {
284        let pattern = EventPattern {
285            kind: Some(EventKind::FileWrite),
286            agent: Some("admin".to_string()),
287            target: None,
288            task_id: None,
289            negate_agent: true,
290            target_not: Vec::new(),
291        };
292
293        // Non-admin matches (negated)
294        let event = Event {
295            kind: EventKind::FileWrite,
296            agent: Some("agent-1".to_string()),
297            target: None,
298            task_id: None,
299            metadata: HashMap::new(),
300        };
301        assert!(pattern.matches_event(&event));
302
303        // Admin does NOT match (negated)
304        let admin_event = Event {
305            kind: EventKind::FileWrite,
306            agent: Some("admin".to_string()),
307            target: None,
308            task_id: None,
309            metadata: HashMap::new(),
310        };
311        assert!(!pattern.matches_event(&admin_event));
312
313        // No agent on event: negated match succeeds
314        let no_agent = Event {
315            kind: EventKind::FileWrite,
316            agent: None,
317            target: None,
318            task_id: None,
319            metadata: HashMap::new(),
320        };
321        assert!(pattern.matches_event(&no_agent));
322    }
323
324    #[test]
325    fn test_event_pattern_target_glob() {
326        use crate::matcher::GlobPattern;
327        let pattern = EventPattern {
328            kind: Some(EventKind::FileWrite),
329            agent: None,
330            target: Some(GlobPattern::new("src/**/*.rs")),
331            task_id: None,
332            negate_agent: false,
333            target_not: Vec::new(),
334        };
335
336        let matching = Event {
337            kind: EventKind::FileWrite,
338            agent: None,
339            target: Some("src/weave/mod.rs".to_string()),
340            task_id: None,
341            metadata: HashMap::new(),
342        };
343        assert!(pattern.matches_event(&matching));
344
345        let non_matching = Event {
346            kind: EventKind::FileWrite,
347            agent: None,
348            target: Some("tests/test.rs".to_string()),
349            task_id: None,
350            metadata: HashMap::new(),
351        };
352        assert!(!pattern.matches_event(&non_matching));
353
354        let no_target = Event {
355            kind: EventKind::FileWrite,
356            agent: None,
357            target: None,
358            task_id: None,
359            metadata: HashMap::new(),
360        };
361        assert!(!pattern.matches_event(&no_target));
362    }
363
364    #[test]
365    fn test_event_pattern_target_not_exclusion() {
366        use crate::matcher::GlobPattern;
367        let pattern = EventPattern {
368            kind: Some(EventKind::FileWrite),
369            agent: None,
370            target: None,
371            task_id: None,
372            negate_agent: false,
373            target_not: vec![GlobPattern::new("docs/**"), GlobPattern::new("*.md")],
374        };
375
376        // Normal file passes
377        let ok = Event {
378            kind: EventKind::FileWrite,
379            agent: None,
380            target: Some("src/main.rs".to_string()),
381            task_id: None,
382            metadata: HashMap::new(),
383        };
384        assert!(pattern.matches_event(&ok));
385
386        // Excluded by first pattern
387        let docs = Event {
388            kind: EventKind::FileWrite,
389            agent: None,
390            target: Some("docs/api.md".to_string()),
391            task_id: None,
392            metadata: HashMap::new(),
393        };
394        assert!(!pattern.matches_event(&docs));
395
396        // Excluded by second pattern
397        let md = Event {
398            kind: EventKind::FileWrite,
399            agent: None,
400            target: Some("README.md".to_string()),
401            task_id: None,
402            metadata: HashMap::new(),
403        };
404        assert!(!pattern.matches_event(&md));
405    }
406
407    #[test]
408    fn test_event_pattern_with_task_id() {
409        let pattern = EventPattern {
410            kind: Some(EventKind::TaskClaim),
411            agent: None,
412            target: None,
413            task_id: Some("auth:1.1".to_string()),
414            negate_agent: false,
415            target_not: Vec::new(),
416        };
417
418        let matching = Event {
419            kind: EventKind::TaskClaim,
420            agent: Some("agent-1".to_string()),
421            target: None,
422            task_id: Some("auth:1.1".to_string()),
423            metadata: HashMap::new(),
424        };
425        assert!(pattern.matches_event(&matching));
426
427        let wrong_task = Event {
428            kind: EventKind::TaskClaim,
429            agent: None,
430            target: None,
431            task_id: Some("auth:1.2".to_string()),
432            metadata: HashMap::new(),
433        };
434        assert!(!pattern.matches_event(&wrong_task));
435
436        let no_task = Event {
437            kind: EventKind::TaskClaim,
438            agent: None,
439            target: None,
440            task_id: None,
441            metadata: HashMap::new(),
442        };
443        assert!(!pattern.matches_event(&no_task));
444    }
445
446    #[test]
447    fn test_event_pattern_wildcard_matches_all_kinds() {
448        // Pattern with no kind matches any kind
449        let pattern = EventPattern {
450            kind: None,
451            agent: None,
452            target: None,
453            task_id: None,
454            negate_agent: false,
455            target_not: Vec::new(),
456        };
457
458        for kind in [EventKind::FileWrite, EventKind::Commit, EventKind::Build] {
459            let event = Event {
460                kind,
461                agent: None,
462                target: None,
463                task_id: None,
464                metadata: HashMap::new(),
465            };
466            assert!(pattern.matches_event(&event));
467        }
468    }
469
470    #[test]
471    fn test_event_kind_all_variants_roundtrip() {
472        let variants = [
473            "FileWrite", "FileCreate", "DependencyAdd", "DependencyRemove",
474            "SchemaChange", "ApiChange", "ConfigChange", "TestRun",
475            "TestPass", "TestFail", "LintPass", "LintFail",
476            "Commit", "Build", "TaskClaim", "TaskComplete", "DangerousCommand",
477        ];
478        for name in variants {
479            let kind = EventKind::parse(name);
480            assert_eq!(kind.as_str(), name, "Roundtrip failed for {}", name);
481        }
482    }
483
484    #[test]
485    fn test_event_kind_custom_roundtrip() {
486        let kind = EventKind::parse("MyCustomEvent");
487        assert_eq!(kind.as_str(), "MyCustomEvent");
488        match kind {
489            EventKind::Custom(s) => assert_eq!(s, "MyCustomEvent"),
490            _ => panic!("Expected Custom variant"),
491        }
492    }
493
494    #[test]
495    fn test_event_serde_roundtrip() {
496        let event = Event {
497            kind: EventKind::FileWrite,
498            agent: Some("agent-1".to_string()),
499            target: Some("src/main.rs".to_string()),
500            task_id: None,
501            metadata: HashMap::new(),
502        };
503        let json = serde_json::to_string(&event).unwrap();
504        let parsed: Event = serde_json::from_str(&json).unwrap();
505        assert_eq!(event, parsed);
506    }
507}