phi_core/config/reference.rs
1//! Generic ID reference protocol for config objects.
2//!
3//! Any config field that references another object uses the `{{...}}` syntax:
4//!
5//! | Pattern | Meaning |
6//! |---------|---------|
7//! | `{{type.name}}` | Qualified reference, recreate |
8//! | `{{%type.name%}}` | Qualified reference, no recreation if exists |
9//! | `{{name}}` | Unqualified (unique resolve), recreate |
10//! | `{{%name%}}` | Unqualified (unique resolve), no recreation |
11//! | `{{#system_id#}}` | Literal system ID, no recreation |
12//!
13//! ## Resolution rules
14//!
15//! - **Qualified**: look up `name` in the specified `type` namespace
16//! (e.g., `agent_profile` → `[[agent.profile.instances]]`)
17//! - **Unqualified**: search all namespaces; must resolve uniquely or error
18//! - **`%` delimiters**: skip creation if an object with matching description
19//! already exists. If multiple matches, use the one with the latest creation date.
20//! - **No `%`**: always create a new instance
21//! - **`#` delimiters**: literal system ID (already exists in external system)
22//!
23//! ## Namespaces
24//!
25//! | Qualified prefix | Config section |
26//! |-----------------|----------------|
27//! | `agent_profile` | `[[agent.profile.instances]]` |
28//! | `provider` | `[[provider.instances]]` |
29//! | `sub_agent` | `[[sub_agents.instances]]` |
30
31use serde::{Deserialize, Serialize};
32
33/// A parsed config object reference.
34///
35/// Produced by [`parse_config_ref`] from a raw string in a config file.
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(tag = "kind", rename_all = "snake_case")]
38pub enum ConfigRef {
39 /// `{{type.name}}` or `{{%type.name%}}` — qualified reference.
40 Qualified {
41 /// The namespace type (e.g., `"agent_profile"`, `"provider"`, `"sub_agent"`).
42 ref_type: String,
43 /// The instance name within that namespace.
44 name: String,
45 /// Whether to recreate the object if it already exists.
46 /// `false` when `%` delimiters are used.
47 recreate: bool,
48 },
49 /// `{{name}}` or `{{%name%}}` — unqualified reference (must resolve uniquely).
50 Unqualified {
51 /// The instance name (searched across all namespaces).
52 name: String,
53 /// Whether to recreate the object if it already exists.
54 recreate: bool,
55 },
56 /// `{{#system_id#}}` — literal system ID, no recreation.
57 SystemId {
58 /// The actual system ID.
59 id: String,
60 },
61 /// Plain string — not a `{{...}}` reference.
62 Literal(String),
63}
64
65impl ConfigRef {
66 /// Returns the effective name for config-internal lookups.
67 ///
68 /// For `Qualified`, returns the `name`. For `Unqualified`, returns the `name`.
69 /// For `SystemId`, returns the `id`. For `Literal`, returns the raw string.
70 pub fn effective_name(&self) -> &str {
71 match self {
72 Self::Qualified { name, .. } => name,
73 Self::Unqualified { name, .. } => name,
74 Self::SystemId { id } => id,
75 Self::Literal(s) => s,
76 }
77 }
78
79 /// Whether this reference requests recreation.
80 pub fn should_recreate(&self) -> bool {
81 match self {
82 Self::Qualified { recreate, .. } => *recreate,
83 Self::Unqualified { recreate, .. } => *recreate,
84 Self::SystemId { .. } => false,
85 Self::Literal(_) => true,
86 }
87 }
88
89 /// Whether this is a `{{...}}` reference (not a plain literal).
90 pub fn is_reference(&self) -> bool {
91 !matches!(self, Self::Literal(_))
92 }
93}
94
95/// Parse a string that may contain a `{{...}}` reference pattern.
96///
97/// # Examples
98///
99/// ```
100/// use phi_core::config::reference::{parse_config_ref, ConfigRef};
101///
102/// // Qualified, recreate
103/// assert_eq!(
104/// parse_config_ref("{{agent_profile.coder}}"),
105/// ConfigRef::Qualified { ref_type: "agent_profile".into(), name: "coder".into(), recreate: true }
106/// );
107///
108/// // Qualified, no recreation
109/// assert_eq!(
110/// parse_config_ref("{{%provider.openai%}}"),
111/// ConfigRef::Qualified { ref_type: "provider".into(), name: "openai".into(), recreate: false }
112/// );
113///
114/// // Unqualified, recreate
115/// assert_eq!(
116/// parse_config_ref("{{coder}}"),
117/// ConfigRef::Unqualified { name: "coder".into(), recreate: true }
118/// );
119///
120/// // System ID
121/// assert_eq!(
122/// parse_config_ref("{{#fctsidd-abc-123#}}"),
123/// ConfigRef::SystemId { id: "fctsidd-abc-123".into() }
124/// );
125///
126/// // Plain string
127/// assert_eq!(
128/// parse_config_ref("just-a-string"),
129/// ConfigRef::Literal("just-a-string".into())
130/// );
131/// ```
132pub fn parse_config_ref(s: &str) -> ConfigRef {
133 let trimmed = s.trim();
134
135 // Must start with {{ and end with }}
136 if !trimmed.starts_with("{{") || !trimmed.ends_with("}}") {
137 return ConfigRef::Literal(s.to_string());
138 }
139
140 let inner = &trimmed[2..trimmed.len() - 2];
141
142 if inner.is_empty() {
143 return ConfigRef::Literal(s.to_string());
144 }
145
146 // {{#system_id#}} — literal system ID
147 if inner.starts_with('#') && inner.ends_with('#') && inner.len() > 2 {
148 let id = &inner[1..inner.len() - 1];
149 return ConfigRef::SystemId { id: id.to_string() };
150 }
151
152 // {{%name%}} or {{%type.name%}} — no recreation
153 if inner.starts_with('%') && inner.ends_with('%') && inner.len() > 2 {
154 let body = &inner[1..inner.len() - 1];
155 return parse_qualified_or_unqualified(body, false);
156 }
157
158 // {{name}} or {{type.name}} — recreate
159 parse_qualified_or_unqualified(inner, true)
160}
161
162/// Parse the inner body (after stripping `{{`, `}}`, and optional `%`) into
163/// either a Qualified or Unqualified ref.
164fn parse_qualified_or_unqualified(body: &str, recreate: bool) -> ConfigRef {
165 // Check for type.name pattern (first dot separates type from name)
166 if let Some(dot_pos) = body.find('.') {
167 let ref_type = &body[..dot_pos];
168 let name = &body[dot_pos + 1..];
169 if !ref_type.is_empty() && !name.is_empty() {
170 return ConfigRef::Qualified {
171 ref_type: ref_type.to_string(),
172 name: name.to_string(),
173 recreate,
174 };
175 }
176 }
177
178 // Unqualified — just a name
179 ConfigRef::Unqualified {
180 name: body.to_string(),
181 recreate,
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn test_qualified_recreate() {
191 assert_eq!(
192 parse_config_ref("{{agent_profile.coder}}"),
193 ConfigRef::Qualified {
194 ref_type: "agent_profile".into(),
195 name: "coder".into(),
196 recreate: true,
197 }
198 );
199 }
200
201 #[test]
202 fn test_qualified_no_recreate() {
203 assert_eq!(
204 parse_config_ref("{{%provider.openai%}}"),
205 ConfigRef::Qualified {
206 ref_type: "provider".into(),
207 name: "openai".into(),
208 recreate: false,
209 }
210 );
211 }
212
213 #[test]
214 fn test_unqualified_recreate() {
215 assert_eq!(
216 parse_config_ref("{{coder}}"),
217 ConfigRef::Unqualified {
218 name: "coder".into(),
219 recreate: true,
220 }
221 );
222 }
223
224 #[test]
225 fn test_unqualified_no_recreate() {
226 assert_eq!(
227 parse_config_ref("{{%coder%}}"),
228 ConfigRef::Unqualified {
229 name: "coder".into(),
230 recreate: false,
231 }
232 );
233 }
234
235 #[test]
236 fn test_system_id() {
237 assert_eq!(
238 parse_config_ref("{{#fctsidd-abc-123#}}"),
239 ConfigRef::SystemId {
240 id: "fctsidd-abc-123".into(),
241 }
242 );
243 }
244
245 #[test]
246 fn test_literal() {
247 assert_eq!(
248 parse_config_ref("just-a-string"),
249 ConfigRef::Literal("just-a-string".into())
250 );
251 }
252
253 #[test]
254 fn test_empty_braces() {
255 assert_eq!(parse_config_ref("{{}}"), ConfigRef::Literal("{{}}".into()));
256 }
257
258 #[test]
259 fn test_effective_name() {
260 let r = parse_config_ref("{{agent_profile.coder}}");
261 assert_eq!(r.effective_name(), "coder");
262
263 let r = parse_config_ref("{{%coder%}}");
264 assert_eq!(r.effective_name(), "coder");
265
266 let r = parse_config_ref("{{#sys-id#}}");
267 assert_eq!(r.effective_name(), "sys-id");
268 }
269
270 #[test]
271 fn test_should_recreate() {
272 assert!(parse_config_ref("{{coder}}").should_recreate());
273 assert!(parse_config_ref("{{agent_profile.coder}}").should_recreate());
274 assert!(!parse_config_ref("{{%coder%}}").should_recreate());
275 assert!(!parse_config_ref("{{#sys-id#}}").should_recreate());
276 }
277}