cpex_core/visitor.rs
1// Location: ./crates/cpex-core/src/visitor.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// `ConfigVisitor` — extension point for external orchestrators (APL,
7// future Rego/Cedar-direct/custom) to participate in unified-config
8// loading without cpex-core taking a dep on any specific orchestrator.
9//
10// # How it fits
11//
12// The host calls `PluginManager::load_config_yaml(yaml)`. cpex-core
13// parses the YAML twice (once into a typed `CpexConfig`, once into a
14// raw `serde_yaml::Value`), runs its own plugin instantiation, then
15// walks each registered visitor in registration order:
16//
17// 1. `visit_plugins` — once per visitor, immediately after
18// cpex-core's own plugin instantiation,
19// receiving the parsed `&[PluginConfig]`
20// so the visitor doesn't have to re-parse
21// the root `plugins:` block from raw YAML.
22// 2. `visit_global` — global config block
23// 3. `visit_default` — once per entity_type with a default
24// 4. `visit_policy_bundle` — once per named policy group (tag)
25// 5. `visit_route` — once per route
26//
27// Each visitor sees the **raw YAML** so it can find its own block
28// (e.g. `apl:`) under any section without cpex-core having to know
29// about it. Parsed sibling data is passed alongside (`RouteEntry` for
30// routes) for convenience — e.g. APL needs to know whether a route
31// matches `tool:` or `resource:` to build the annotation key.
32//
33// # Why visit per-section rather than per-whole-config
34//
35// Visitors typically accumulate state across the hierarchy (e.g. APL's
36// visitor compiles globals/defaults/tag-bundles into `CompiledRoute`s
37// kept in visitor state, then merges them into each route at
38// `visit_route`). Per-section calls give the orchestrator a natural
39// place to do that accumulation without re-parsing.
40//
41// # Visit order
42//
43// All sections for one visitor run before the next visitor starts. For
44// single-visitor deployments (the common case) this is identical to
45// any other ordering; for multi-visitor it gives each visitor a
46// consistent view of its own internal state. Visitor methods are
47// invoked synchronously — no async runtime needed at load time.
48
49use std::sync::Arc;
50
51use crate::config::RouteEntry;
52use crate::manager::PluginManager;
53use crate::plugin::PluginConfig;
54
55/// Error type returned by a config visitor. Boxed `dyn Error` so each
56/// orchestrator can carry its own error variants (parse errors, missing
57/// plugin references, etc.) without cpex-core having to enumerate them.
58pub type VisitorError = Box<dyn std::error::Error + Send + Sync>;
59
60/// Extension point for external orchestrators to participate in unified
61/// config loading. Register via [`PluginManager::register_visitor`];
62/// invoked during [`PluginManager::load_config_yaml`].
63///
64/// All methods have default no-op implementations — a visitor only
65/// overrides the sections it cares about.
66pub trait ConfigVisitor: Send + Sync {
67 /// Stable identifier for diagnostics — included in error contexts
68 /// if a visitor method returns Err. Convention: short kebab-case
69 /// matching the orchestrator's YAML key (e.g. `"apl"`, `"rego"`).
70 fn name(&self) -> &str;
71
72 /// Visit the typed plugin declarations from the root `plugins:`
73 /// block. Called once per visitor, immediately after cpex-core's
74 /// own plugin instantiation completes and before any hierarchy
75 /// section is walked. Visitors that need a per-name registry of
76 /// hook / capability / on_error metadata can populate it here
77 /// without re-parsing the YAML — cpex-core has already validated
78 /// the block (no duplicate names, etc.) by this point.
79 fn visit_plugins(
80 &self,
81 _mgr: &Arc<PluginManager>,
82 _plugins: &[PluginConfig],
83 ) -> Result<(), VisitorError> {
84 Ok(())
85 }
86
87 /// Visit the top-level `global:` block. `yaml` is the raw value at
88 /// that path, or `Value::Null` if `global:` is absent.
89 fn visit_global(
90 &self,
91 _mgr: &Arc<PluginManager>,
92 _yaml: &serde_yaml::Value,
93 ) -> Result<(), VisitorError> {
94 Ok(())
95 }
96
97 /// Visit one entry in `global.defaults`. Called once per
98 /// `(entity_type, default_block)` pair. `yaml` is the raw value at
99 /// `global.defaults.<entity_type>`.
100 fn visit_default(
101 &self,
102 _mgr: &Arc<PluginManager>,
103 _entity_type: &str,
104 _yaml: &serde_yaml::Value,
105 ) -> Result<(), VisitorError> {
106 Ok(())
107 }
108
109 /// Visit one entry in `global.policies` (a named tag bundle).
110 /// Called once per `(tag, policy_group)` pair. `yaml` is the raw
111 /// value at `global.policies.<tag>`.
112 fn visit_policy_bundle(
113 &self,
114 _mgr: &Arc<PluginManager>,
115 _tag: &str,
116 _yaml: &serde_yaml::Value,
117 ) -> Result<(), VisitorError> {
118 Ok(())
119 }
120
121 /// Visit one route entry. `yaml` is the raw value at `routes[i]`
122 /// (so orchestrator can find its own block like `apl:`); `parsed`
123 /// is the typed `RouteEntry` cpex-core deserialized (so the
124 /// orchestrator can read `tool`/`resource`/`prompt`/`llm`,
125 /// `meta.scope`, `meta.tags`, etc. without re-parsing).
126 fn visit_route(
127 &self,
128 _mgr: &Arc<PluginManager>,
129 _yaml: &serde_yaml::Value,
130 _parsed: &RouteEntry,
131 ) -> Result<(), VisitorError> {
132 Ok(())
133 }
134}