aof_core/
registry.rs

1// AOF Core - Resource Registries
2//
3// Unified registries for loading, indexing, and resolving all AOF resource types.
4// Each registry provides:
5// - Directory loading (load all resources from a path)
6// - Name-based lookup
7// - Type-safe access to resources
8
9use crate::agent::AgentConfig;
10use crate::agentflow::AgentFlow;
11use crate::binding::FlowBinding;
12use crate::context::Context;
13use crate::error::{AofError, AofResult};
14use crate::trigger::Trigger;
15
16use std::collections::HashMap;
17use std::path::Path;
18
19/// Common trait for all resource registries
20pub trait Registry<T> {
21    /// Load all resources from a directory
22    fn load_directory(&mut self, path: &Path) -> AofResult<usize>;
23
24    /// Get a resource by name
25    fn get(&self, name: &str) -> Option<&T>;
26
27    /// Get all resources
28    fn get_all(&self) -> Vec<&T>;
29
30    /// Register a resource
31    fn register(&mut self, resource: T) -> AofResult<()>;
32
33    /// Get the count of resources
34    fn count(&self) -> usize;
35
36    /// Check if a resource exists
37    fn exists(&self, name: &str) -> bool {
38        self.get(name).is_some()
39    }
40}
41
42// ============================================================================
43// Agent Registry
44// ============================================================================
45
46/// Registry for Agent resources
47#[derive(Debug, Default)]
48pub struct AgentRegistry {
49    agents: HashMap<String, AgentConfig>,
50}
51
52impl AgentRegistry {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Get agent names
58    pub fn names(&self) -> Vec<&str> {
59        self.agents.keys().map(|s| s.as_str()).collect()
60    }
61}
62
63impl Registry<AgentConfig> for AgentRegistry {
64    fn load_directory(&mut self, path: &Path) -> AofResult<usize> {
65        if !path.exists() {
66            return Ok(0);
67        }
68
69        let mut count = 0;
70        for entry in std::fs::read_dir(path)? {
71            let entry = entry?;
72            let file_path = entry.path();
73
74            if file_path.extension().map_or(false, |e| e == "yaml" || e == "yml") {
75                // Skip non-Agent YAML files (Trigger, Fleet, Flow, etc.)
76                if !yaml_file_has_kind(&file_path, "Agent") {
77                    tracing::debug!("Skipping non-Agent file: {:?}", file_path);
78                    continue;
79                }
80
81                match load_yaml_file::<AgentConfig>(&file_path) {
82                    Ok(agent) => {
83                        let name = agent.name.clone();
84                        self.agents.insert(name.clone(), agent);
85                        tracing::debug!("Loaded agent: {}", name);
86                        count += 1;
87                    }
88                    Err(e) => {
89                        tracing::warn!("Failed to load agent from {:?}: {}", file_path, e);
90                    }
91                }
92            }
93        }
94
95        Ok(count)
96    }
97
98    fn get(&self, name: &str) -> Option<&AgentConfig> {
99        self.agents.get(name)
100    }
101
102    fn get_all(&self) -> Vec<&AgentConfig> {
103        self.agents.values().collect()
104    }
105
106    fn register(&mut self, resource: AgentConfig) -> AofResult<()> {
107        let name = resource.name.clone();
108        self.agents.insert(name, resource);
109        Ok(())
110    }
111
112    fn count(&self) -> usize {
113        self.agents.len()
114    }
115}
116
117// ============================================================================
118// Context Registry
119// ============================================================================
120
121/// Registry for Context resources
122#[derive(Debug, Default)]
123pub struct ContextRegistry {
124    contexts: HashMap<String, Context>,
125}
126
127impl ContextRegistry {
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Get context names
133    pub fn names(&self) -> Vec<&str> {
134        self.contexts.keys().map(|s| s.as_str()).collect()
135    }
136
137    /// Get mutable reference to a context (for env var expansion)
138    pub fn get_mut(&mut self, name: &str) -> Option<&mut Context> {
139        self.contexts.get_mut(name)
140    }
141
142    /// Expand environment variables in all contexts
143    pub fn expand_all_env_vars(&mut self) {
144        for context in self.contexts.values_mut() {
145            context.expand_env_vars();
146        }
147    }
148}
149
150impl Registry<Context> for ContextRegistry {
151    fn load_directory(&mut self, path: &Path) -> AofResult<usize> {
152        if !path.exists() {
153            return Ok(0);
154        }
155
156        let mut count = 0;
157        for entry in std::fs::read_dir(path)? {
158            let entry = entry?;
159            let file_path = entry.path();
160
161            if file_path.extension().map_or(false, |e| e == "yaml" || e == "yml") {
162                match load_yaml_file::<Context>(&file_path) {
163                    Ok(mut context) => {
164                        context.expand_env_vars();
165                        if let Err(e) = context.validate() {
166                            tracing::warn!("Invalid context in {:?}: {}", file_path, e);
167                            continue;
168                        }
169                        let name = context.metadata.name.clone();
170                        self.contexts.insert(name.clone(), context);
171                        tracing::debug!("Loaded context: {}", name);
172                        count += 1;
173                    }
174                    Err(e) => {
175                        tracing::warn!("Failed to load context from {:?}: {}", file_path, e);
176                    }
177                }
178            }
179        }
180
181        Ok(count)
182    }
183
184    fn get(&self, name: &str) -> Option<&Context> {
185        self.contexts.get(name)
186    }
187
188    fn get_all(&self) -> Vec<&Context> {
189        self.contexts.values().collect()
190    }
191
192    fn register(&mut self, resource: Context) -> AofResult<()> {
193        resource.validate().map_err(|e| AofError::Config(e))?;
194        let name = resource.metadata.name.clone();
195        self.contexts.insert(name, resource);
196        Ok(())
197    }
198
199    fn count(&self) -> usize {
200        self.contexts.len()
201    }
202}
203
204// ============================================================================
205// Trigger Registry
206// ============================================================================
207
208/// Registry for Trigger resources
209#[derive(Debug, Default)]
210pub struct TriggerRegistry {
211    triggers: HashMap<String, Trigger>,
212}
213
214impl TriggerRegistry {
215    pub fn new() -> Self {
216        Self::default()
217    }
218
219    /// Get trigger names
220    pub fn names(&self) -> Vec<&str> {
221        self.triggers.keys().map(|s| s.as_str()).collect()
222    }
223
224    /// Get triggers by type
225    pub fn get_by_type(&self, trigger_type: crate::trigger::StandaloneTriggerType) -> Vec<&Trigger> {
226        self.triggers
227            .values()
228            .filter(|t| t.spec.trigger_type == trigger_type)
229            .collect()
230    }
231
232    /// Expand environment variables in all triggers
233    pub fn expand_all_env_vars(&mut self) {
234        for trigger in self.triggers.values_mut() {
235            trigger.expand_env_vars();
236        }
237    }
238}
239
240impl Registry<Trigger> for TriggerRegistry {
241    fn load_directory(&mut self, path: &Path) -> AofResult<usize> {
242        if !path.exists() {
243            return Ok(0);
244        }
245
246        let mut count = 0;
247        for entry in std::fs::read_dir(path)? {
248            let entry = entry?;
249            let file_path = entry.path();
250
251            if file_path.extension().map_or(false, |e| e == "yaml" || e == "yml") {
252                match load_yaml_file::<Trigger>(&file_path) {
253                    Ok(mut trigger) => {
254                        trigger.expand_env_vars();
255                        if let Err(e) = trigger.validate() {
256                            tracing::warn!("Invalid trigger in {:?}: {}", file_path, e);
257                            continue;
258                        }
259                        let name = trigger.metadata.name.clone();
260                        self.triggers.insert(name.clone(), trigger);
261                        tracing::debug!("Loaded trigger: {}", name);
262                        count += 1;
263                    }
264                    Err(e) => {
265                        tracing::warn!("Failed to load trigger from {:?}: {}", file_path, e);
266                    }
267                }
268            }
269        }
270
271        Ok(count)
272    }
273
274    fn get(&self, name: &str) -> Option<&Trigger> {
275        self.triggers.get(name)
276    }
277
278    fn get_all(&self) -> Vec<&Trigger> {
279        self.triggers.values().collect()
280    }
281
282    fn register(&mut self, resource: Trigger) -> AofResult<()> {
283        resource.validate().map_err(|e| AofError::Config(e))?;
284        let name = resource.metadata.name.clone();
285        self.triggers.insert(name, resource);
286        Ok(())
287    }
288
289    fn count(&self) -> usize {
290        self.triggers.len()
291    }
292}
293
294// ============================================================================
295// Flow Registry
296// ============================================================================
297
298/// Registry for AgentFlow resources
299#[derive(Debug, Default)]
300pub struct FlowRegistry {
301    flows: HashMap<String, AgentFlow>,
302}
303
304impl FlowRegistry {
305    pub fn new() -> Self {
306        Self::default()
307    }
308
309    /// Get flow names
310    pub fn names(&self) -> Vec<&str> {
311        self.flows.keys().map(|s| s.as_str()).collect()
312    }
313}
314
315impl Registry<AgentFlow> for FlowRegistry {
316    fn load_directory(&mut self, path: &Path) -> AofResult<usize> {
317        if !path.exists() {
318            return Ok(0);
319        }
320
321        let mut count = 0;
322        for entry in std::fs::read_dir(path)? {
323            let entry = entry?;
324            let file_path = entry.path();
325
326            if file_path.extension().map_or(false, |e| e == "yaml" || e == "yml") {
327                match load_yaml_file::<AgentFlow>(&file_path) {
328                    Ok(flow) => {
329                        if let Err(e) = flow.validate() {
330                            tracing::warn!("Invalid flow in {:?}: {}", file_path, e);
331                            continue;
332                        }
333                        let name = flow.metadata.name.clone();
334                        self.flows.insert(name.clone(), flow);
335                        tracing::debug!("Loaded flow: {}", name);
336                        count += 1;
337                    }
338                    Err(e) => {
339                        tracing::warn!("Failed to load flow from {:?}: {}", file_path, e);
340                    }
341                }
342            }
343        }
344
345        Ok(count)
346    }
347
348    fn get(&self, name: &str) -> Option<&AgentFlow> {
349        self.flows.get(name)
350    }
351
352    fn get_all(&self) -> Vec<&AgentFlow> {
353        self.flows.values().collect()
354    }
355
356    fn register(&mut self, resource: AgentFlow) -> AofResult<()> {
357        resource.validate().map_err(|e| AofError::Config(e))?;
358        let name = resource.metadata.name.clone();
359        self.flows.insert(name, resource);
360        Ok(())
361    }
362
363    fn count(&self) -> usize {
364        self.flows.len()
365    }
366}
367
368// ============================================================================
369// Binding Registry
370// ============================================================================
371
372/// Registry for FlowBinding resources
373#[derive(Debug, Default)]
374pub struct BindingRegistry {
375    bindings: HashMap<String, FlowBinding>,
376}
377
378impl BindingRegistry {
379    pub fn new() -> Self {
380        Self::default()
381    }
382
383    /// Get binding names
384    pub fn names(&self) -> Vec<&str> {
385        self.bindings.keys().map(|s| s.as_str()).collect()
386    }
387
388    /// Get all bindings for a specific trigger
389    pub fn get_bindings_for_trigger(&self, trigger_name: &str) -> Vec<&FlowBinding> {
390        self.bindings
391            .values()
392            .filter(|b| b.spec.trigger == trigger_name && b.spec.enabled)
393            .collect()
394    }
395
396    /// Get all bindings for a specific context
397    pub fn get_bindings_for_context(&self, context_name: &str) -> Vec<&FlowBinding> {
398        self.bindings
399            .values()
400            .filter(|b| b.spec.context.as_deref() == Some(context_name) && b.spec.enabled)
401            .collect()
402    }
403
404    /// Get all enabled bindings
405    pub fn get_enabled(&self) -> Vec<&FlowBinding> {
406        self.bindings.values().filter(|b| b.spec.enabled).collect()
407    }
408
409    /// Find best matching binding for a message
410    pub fn find_best_match(
411        &self,
412        trigger_name: &str,
413        channel: Option<&str>,
414        user: Option<&str>,
415        text: Option<&str>,
416    ) -> Option<&FlowBinding> {
417        let bindings = self.get_bindings_for_trigger(trigger_name);
418
419        bindings
420            .into_iter()
421            .filter(|b| b.matches(channel, user, text))
422            .max_by_key(|b| b.match_score(channel, user, text))
423    }
424}
425
426impl Registry<FlowBinding> for BindingRegistry {
427    fn load_directory(&mut self, path: &Path) -> AofResult<usize> {
428        if !path.exists() {
429            return Ok(0);
430        }
431
432        let mut count = 0;
433        for entry in std::fs::read_dir(path)? {
434            let entry = entry?;
435            let file_path = entry.path();
436
437            if file_path.extension().map_or(false, |e| e == "yaml" || e == "yml") {
438                match load_yaml_file::<FlowBinding>(&file_path) {
439                    Ok(binding) => {
440                        if let Err(e) = binding.validate() {
441                            tracing::warn!("Invalid binding in {:?}: {}", file_path, e);
442                            continue;
443                        }
444                        let name = binding.metadata.name.clone();
445                        self.bindings.insert(name.clone(), binding);
446                        tracing::debug!("Loaded binding: {}", name);
447                        count += 1;
448                    }
449                    Err(e) => {
450                        tracing::warn!("Failed to load binding from {:?}: {}", file_path, e);
451                    }
452                }
453            }
454        }
455
456        Ok(count)
457    }
458
459    fn get(&self, name: &str) -> Option<&FlowBinding> {
460        self.bindings.get(name)
461    }
462
463    fn get_all(&self) -> Vec<&FlowBinding> {
464        self.bindings.values().collect()
465    }
466
467    fn register(&mut self, resource: FlowBinding) -> AofResult<()> {
468        resource.validate().map_err(|e| AofError::Config(e))?;
469        let name = resource.metadata.name.clone();
470        self.bindings.insert(name, resource);
471        Ok(())
472    }
473
474    fn count(&self) -> usize {
475        self.bindings.len()
476    }
477}
478
479// ============================================================================
480// Resource Manager (Unified Access)
481// ============================================================================
482
483/// Unified resource manager holding all registries
484#[derive(Debug, Default)]
485pub struct ResourceManager {
486    pub agents: AgentRegistry,
487    pub contexts: ContextRegistry,
488    pub triggers: TriggerRegistry,
489    pub flows: FlowRegistry,
490    pub bindings: BindingRegistry,
491}
492
493impl ResourceManager {
494    pub fn new() -> Self {
495        Self::default()
496    }
497
498    /// Load all resources from a directory structure
499    ///
500    /// Expected structure:
501    /// ```text
502    /// root/
503    /// ├── agents/
504    /// ├── contexts/
505    /// ├── triggers/
506    /// ├── flows/
507    /// └── bindings/
508    /// ```
509    pub fn load_directory(&mut self, root: &Path) -> AofResult<ResourceLoadSummary> {
510        let mut summary = ResourceLoadSummary::default();
511
512        // Load agents
513        let agents_dir = root.join("agents");
514        if agents_dir.exists() {
515            summary.agents = self.agents.load_directory(&agents_dir)?;
516        }
517
518        // Load contexts
519        let contexts_dir = root.join("contexts");
520        if contexts_dir.exists() {
521            summary.contexts = self.contexts.load_directory(&contexts_dir)?;
522        }
523
524        // Load triggers
525        let triggers_dir = root.join("triggers");
526        if triggers_dir.exists() {
527            summary.triggers = self.triggers.load_directory(&triggers_dir)?;
528        }
529
530        // Load flows
531        let flows_dir = root.join("flows");
532        if flows_dir.exists() {
533            summary.flows = self.flows.load_directory(&flows_dir)?;
534        }
535
536        // Load bindings
537        let bindings_dir = root.join("bindings");
538        if bindings_dir.exists() {
539            summary.bindings = self.bindings.load_directory(&bindings_dir)?;
540        }
541
542        Ok(summary)
543    }
544
545    /// Validate all cross-references between resources
546    pub fn validate_references(&self) -> Vec<ValidationError> {
547        let mut errors = Vec::new();
548
549        // Validate binding references
550        for binding in self.bindings.get_all() {
551            // Check trigger reference
552            if !self.triggers.exists(&binding.spec.trigger) {
553                errors.push(ValidationError {
554                    resource_type: "FlowBinding".to_string(),
555                    resource_name: binding.metadata.name.clone(),
556                    field: "trigger".to_string(),
557                    message: format!("Referenced trigger '{}' not found", binding.spec.trigger),
558                });
559            }
560
561            // Check context reference (if specified)
562            if let Some(ref context_name) = binding.spec.context {
563                if !self.contexts.exists(context_name) {
564                    errors.push(ValidationError {
565                        resource_type: "FlowBinding".to_string(),
566                        resource_name: binding.metadata.name.clone(),
567                        field: "context".to_string(),
568                        message: format!("Referenced context '{}' not found", context_name),
569                    });
570                }
571            }
572
573            // Check flow reference
574            if !binding.spec.flow.is_empty() && !self.flows.exists(&binding.spec.flow) {
575                errors.push(ValidationError {
576                    resource_type: "FlowBinding".to_string(),
577                    resource_name: binding.metadata.name.clone(),
578                    field: "flow".to_string(),
579                    message: format!("Referenced flow '{}' not found", binding.spec.flow),
580                });
581            }
582
583            // Check agent reference (if specified)
584            if let Some(ref agent_name) = binding.spec.agent {
585                if !self.agents.exists(agent_name) {
586                    errors.push(ValidationError {
587                        resource_type: "FlowBinding".to_string(),
588                        resource_name: binding.metadata.name.clone(),
589                        field: "agent".to_string(),
590                        message: format!("Referenced agent '{}' not found", agent_name),
591                    });
592                }
593            }
594        }
595
596        errors
597    }
598
599    /// Get summary of loaded resources
600    pub fn summary(&self) -> ResourceLoadSummary {
601        ResourceLoadSummary {
602            agents: self.agents.count(),
603            contexts: self.contexts.count(),
604            triggers: self.triggers.count(),
605            flows: self.flows.count(),
606            bindings: self.bindings.count(),
607        }
608    }
609}
610
611/// Summary of loaded resources
612#[derive(Debug, Default, Clone)]
613pub struct ResourceLoadSummary {
614    pub agents: usize,
615    pub contexts: usize,
616    pub triggers: usize,
617    pub flows: usize,
618    pub bindings: usize,
619}
620
621impl ResourceLoadSummary {
622    pub fn total(&self) -> usize {
623        self.agents + self.contexts + self.triggers + self.flows + self.bindings
624    }
625}
626
627impl std::fmt::Display for ResourceLoadSummary {
628    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629        write!(
630            f,
631            "Loaded {} resources: {} agents, {} contexts, {} triggers, {} flows, {} bindings",
632            self.total(),
633            self.agents,
634            self.contexts,
635            self.triggers,
636            self.flows,
637            self.bindings
638        )
639    }
640}
641
642/// Validation error for cross-references
643#[derive(Debug, Clone)]
644pub struct ValidationError {
645    pub resource_type: String,
646    pub resource_name: String,
647    pub field: String,
648    pub message: String,
649}
650
651impl std::fmt::Display for ValidationError {
652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653        write!(
654            f,
655            "{} '{}' field '{}': {}",
656            self.resource_type, self.resource_name, self.field, self.message
657        )
658    }
659}
660
661// ============================================================================
662// Helper Functions
663// ============================================================================
664
665/// Load a YAML file and deserialize to type T
666fn load_yaml_file<T: serde::de::DeserializeOwned>(path: &Path) -> AofResult<T> {
667    let content = std::fs::read_to_string(path)?;
668    let resource: T = serde_yaml::from_str(&content)?;
669    Ok(resource)
670}
671
672/// Check if a YAML file has a specific `kind` field value
673/// Returns true if the file has the expected kind, false otherwise
674fn yaml_file_has_kind(path: &Path, expected_kind: &str) -> bool {
675    // Helper struct to just parse kind field
676    #[derive(serde::Deserialize)]
677    struct KindCheck {
678        kind: Option<String>,
679    }
680
681    let content = match std::fs::read_to_string(path) {
682        Ok(c) => c,
683        Err(_) => return false,
684    };
685
686    match serde_yaml::from_str::<KindCheck>(&content) {
687        Ok(check) => check.kind.as_deref() == Some(expected_kind),
688        Err(_) => false,
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use std::io::Write;
696    use tempfile::TempDir;
697
698    #[test]
699    fn test_agent_registry() {
700        let mut registry = AgentRegistry::new();
701
702        let agent = AgentConfig {
703            name: "test-agent".to_string(),
704            model: "google:gemini-2.5-flash".to_string(),
705            system_prompt: Some("Test prompt".to_string()),
706            provider: None,
707            tools: vec![],
708            mcp_servers: vec![],
709            memory: None,
710            max_context_messages: 10,
711            max_iterations: 10,
712            temperature: 0.7,
713            max_tokens: None,
714            output_schema: None,
715            extra: HashMap::new(),
716        };
717
718        registry.register(agent).unwrap();
719        assert_eq!(registry.count(), 1);
720        assert!(registry.exists("test-agent"));
721        assert!(registry.get("test-agent").is_some());
722    }
723
724    #[test]
725    fn test_context_registry() {
726        let mut registry = ContextRegistry::new();
727
728        let yaml = r#"
729apiVersion: aof.dev/v1
730kind: Context
731metadata:
732  name: test-context
733spec:
734  namespace: default
735"#;
736        let context: Context = serde_yaml::from_str(yaml).unwrap();
737
738        registry.register(context).unwrap();
739        assert_eq!(registry.count(), 1);
740        assert!(registry.exists("test-context"));
741    }
742
743    #[test]
744    fn test_trigger_registry() {
745        let mut registry = TriggerRegistry::new();
746
747        let yaml = r#"
748apiVersion: aof.dev/v1
749kind: Trigger
750metadata:
751  name: test-trigger
752spec:
753  type: HTTP
754  config: {}
755"#;
756        let trigger: Trigger = serde_yaml::from_str(yaml).unwrap();
757
758        registry.register(trigger).unwrap();
759        assert_eq!(registry.count(), 1);
760        assert!(registry.exists("test-trigger"));
761    }
762
763    #[test]
764    fn test_binding_registry_find_best_match() {
765        let mut registry = BindingRegistry::new();
766
767        // General binding
768        let yaml1 = r#"
769apiVersion: aof.dev/v1
770kind: FlowBinding
771metadata:
772  name: general
773spec:
774  trigger: slack
775  flow: general-flow
776"#;
777        // Specific binding for kubectl
778        let yaml2 = r#"
779apiVersion: aof.dev/v1
780kind: FlowBinding
781metadata:
782  name: kubectl-specific
783spec:
784  trigger: slack
785  flow: k8s-flow
786  match:
787    patterns: [kubectl]
788    channels: [production]
789"#;
790
791        let binding1: FlowBinding = serde_yaml::from_str(yaml1).unwrap();
792        let binding2: FlowBinding = serde_yaml::from_str(yaml2).unwrap();
793
794        registry.register(binding1).unwrap();
795        registry.register(binding2).unwrap();
796
797        // Test that more specific binding wins
798        let best = registry.find_best_match(
799            "slack",
800            Some("production"),
801            None,
802            Some("kubectl get pods"),
803        );
804
805        assert!(best.is_some());
806        assert_eq!(best.unwrap().metadata.name, "kubectl-specific");
807    }
808
809    #[test]
810    fn test_resource_manager_load_directory() {
811        let temp_dir = TempDir::new().unwrap();
812        let root = temp_dir.path();
813
814        // Create directory structure
815        std::fs::create_dir_all(root.join("agents")).unwrap();
816        std::fs::create_dir_all(root.join("contexts")).unwrap();
817        std::fs::create_dir_all(root.join("triggers")).unwrap();
818        std::fs::create_dir_all(root.join("bindings")).unwrap();
819
820        // Write agent file
821        let agent_yaml = r#"
822apiVersion: aof.dev/v1
823kind: Agent
824metadata:
825  name: test-agent
826spec:
827  model: google:gemini-2.5-flash
828"#;
829        let mut file = std::fs::File::create(root.join("agents/test.yaml")).unwrap();
830        file.write_all(agent_yaml.as_bytes()).unwrap();
831
832        // Write context file
833        let context_yaml = r#"
834apiVersion: aof.dev/v1
835kind: Context
836metadata:
837  name: prod
838spec:
839  namespace: production
840"#;
841        let mut file = std::fs::File::create(root.join("contexts/prod.yaml")).unwrap();
842        file.write_all(context_yaml.as_bytes()).unwrap();
843
844        // Load all
845        let mut manager = ResourceManager::new();
846        let summary = manager.load_directory(root).unwrap();
847
848        assert_eq!(summary.agents, 1);
849        assert_eq!(summary.contexts, 1);
850        assert!(manager.agents.exists("test-agent"));
851        assert!(manager.contexts.exists("prod"));
852    }
853
854    #[test]
855    fn test_validate_references() {
856        let mut manager = ResourceManager::new();
857
858        // Add a trigger
859        let trigger_yaml = r#"
860apiVersion: aof.dev/v1
861kind: Trigger
862metadata:
863  name: slack-trigger
864spec:
865  type: HTTP
866  config: {}
867"#;
868        let trigger: Trigger = serde_yaml::from_str(trigger_yaml).unwrap();
869        manager.triggers.register(trigger).unwrap();
870
871        // Add a binding referencing non-existent resources
872        let binding_yaml = r#"
873apiVersion: aof.dev/v1
874kind: FlowBinding
875metadata:
876  name: bad-binding
877spec:
878  trigger: slack-trigger
879  context: non-existent-context
880  flow: non-existent-flow
881"#;
882        let binding: FlowBinding = serde_yaml::from_str(binding_yaml).unwrap();
883        manager.bindings.register(binding).unwrap();
884
885        let errors = manager.validate_references();
886        assert_eq!(errors.len(), 2); // context and flow not found
887    }
888}