1use serde::Deserialize;
30use std::collections::HashMap;
31use std::ffi::OsStr;
32use std::fs;
33use std::path::{Path, PathBuf};
34use thiserror::Error;
35
36pub const RESERVED_LABELS: &[&str] = &["anchor", "facet"];
42
43#[derive(Debug, Clone, Deserialize)]
46pub struct TargetSchema {
47 pub id: String,
49 #[serde(default)]
51 pub operations: Vec<OperationSchema>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
56pub struct OperationSchema {
57 pub name: String,
59 #[serde(default)]
63 pub required_designations: Vec<String>,
64}
65
66#[derive(Debug, Deserialize)]
69struct SchemaFile {
70 #[serde(default)]
71 targets: Vec<TargetSchema>,
72}
73
74#[derive(Debug, Default, Clone)]
81pub struct SchemaRegistry {
82 targets: HashMap<String, TargetSchema>,
83 sources: HashMap<String, String>,
85}
86
87impl SchemaRegistry {
88 pub fn new() -> Self {
91 Self::default()
92 }
93
94 pub fn from_toml(content: &str) -> Result<Self, SchemaError> {
96 let mut reg = Self::new();
97 reg.add_toml_source(content, "<inline>")?;
98 Ok(reg)
99 }
100
101 pub fn from_file(path: &Path) -> Result<Self, SchemaError> {
103 let mut reg = Self::new();
104 reg.add_file(path)?;
105 Ok(reg)
106 }
107
108 pub fn from_dir(dir: &Path) -> Result<Self, SchemaError> {
112 let mut reg = Self::new();
113 let mut entries: Vec<PathBuf> = fs::read_dir(dir)
114 .map_err(|source| SchemaError::Io {
115 path: dir.to_path_buf(),
116 source,
117 })?
118 .filter_map(|res| res.ok().map(|e| e.path()))
119 .filter(|p| p.is_file() && p.extension() == Some(OsStr::new("toml")))
120 .collect();
121 entries.sort();
122 for path in entries {
123 reg.add_file(&path)?;
124 }
125 Ok(reg)
126 }
127
128 pub fn add_file(&mut self, path: &Path) -> Result<(), SchemaError> {
131 let content = fs::read_to_string(path).map_err(|source| SchemaError::Io {
132 path: path.to_path_buf(),
133 source,
134 })?;
135 self.add_toml_source(&content, &path.display().to_string())
136 }
137
138 pub fn add_toml(&mut self, content: &str) -> Result<(), SchemaError> {
142 self.add_toml_source(content, "<inline>")
143 }
144
145 pub fn add_target(&mut self, schema: TargetSchema) -> Result<(), SchemaError> {
148 self.add_target_with_source(schema, "<programmatic>")
149 }
150
151 pub fn get(&self, target: &str) -> Option<&TargetSchema> {
153 self.targets.get(target)
154 }
155
156 pub fn required_designations(&self, target: &str, operation: &str) -> Option<&[String]> {
160 self.targets
161 .get(target)?
162 .operations
163 .iter()
164 .find(|op| op.name == operation)
165 .map(|op| op.required_designations.as_slice())
166 }
167
168 pub fn targets(&self) -> impl Iterator<Item = &TargetSchema> {
170 self.targets.values()
171 }
172
173 pub fn is_empty(&self) -> bool {
175 self.targets.is_empty()
176 }
177
178 fn add_toml_source(&mut self, content: &str, source: &str) -> Result<(), SchemaError> {
179 let parsed: SchemaFile = toml::from_str(content).map_err(|err| SchemaError::Parse {
180 source: PathBuf::from(source),
181 err,
182 })?;
183 for target in parsed.targets {
184 self.add_target_with_source(target, source)?;
185 }
186 Ok(())
187 }
188
189 fn add_target_with_source(
190 &mut self,
191 schema: TargetSchema,
192 source: &str,
193 ) -> Result<(), SchemaError> {
194 validate_target(&schema)?;
195
196 if let Some(first_source) = self.sources.get(&schema.id) {
197 return Err(SchemaError::DuplicateTarget {
198 id: schema.id,
199 first: first_source.clone(),
200 second: source.to_string(),
201 });
202 }
203
204 self.sources.insert(schema.id.clone(), source.to_string());
205 self.targets.insert(schema.id.clone(), schema);
206 Ok(())
207 }
208}
209
210fn validate_target(schema: &TargetSchema) -> Result<(), SchemaError> {
211 let mut seen_ops: std::collections::HashSet<&str> = std::collections::HashSet::new();
212 for op in &schema.operations {
213 if !seen_ops.insert(op.name.as_str()) {
214 return Err(SchemaError::DuplicateOperation {
215 target: schema.id.clone(),
216 op: op.name.clone(),
217 });
218 }
219 for label in &op.required_designations {
220 if RESERVED_LABELS.contains(&label.as_str()) {
221 return Err(SchemaError::ReservedLabel {
222 target: schema.id.clone(),
223 op: op.name.clone(),
224 label: label.clone(),
225 });
226 }
227 }
228 }
229 Ok(())
230}
231
232#[derive(Error, Debug)]
234pub enum SchemaError {
235 #[error("failed to read schema file {}: {source}", path.display())]
236 Io {
237 path: PathBuf,
238 #[source]
239 source: std::io::Error,
240 },
241
242 #[error("failed to parse schema TOML at {}: {err}", source.display())]
243 Parse {
244 source: PathBuf,
245 #[source]
246 err: toml::de::Error,
247 },
248
249 #[error("duplicate target id '{id}' (first declared in {first}, redeclared in {second})")]
250 DuplicateTarget {
251 id: String,
252 first: String,
253 second: String,
254 },
255
256 #[error("target '{target}' declares operation '{op}' more than once")]
257 DuplicateOperation { target: String, op: String },
258
259 #[error(
260 "target '{target}' operation '{op}' lists reserved label '{label}' in required_designations; reserved labels are handled by the engine through a dedicated path and cannot be declared here"
261 )]
262 ReservedLabel {
263 target: String,
264 op: String,
265 label: String,
266 },
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn empty_registry_returns_none_for_lookups() {
275 let reg = SchemaRegistry::new();
276 assert!(reg.is_empty());
277 assert!(reg.get("anything").is_none());
278 assert!(reg.required_designations("a", "b").is_none());
279 }
280
281 #[test]
282 fn parses_single_target_with_required_designations() {
283 let toml = r#"
284[[targets]]
285id = "filesystem:source"
286operations = [
287 { name = "read", required_designations = ["path_prefix"] },
288 { name = "write", required_designations = ["path_prefix"] },
289]
290"#;
291 let reg = SchemaRegistry::from_toml(toml).expect("parse");
292 let req = reg
293 .required_designations("filesystem:source", "read")
294 .expect("op exists");
295 assert_eq!(req, ["path_prefix"]);
296 let req = reg
297 .required_designations("filesystem:source", "write")
298 .expect("op exists");
299 assert_eq!(req, ["path_prefix"]);
300 }
301
302 #[test]
303 fn missing_target_or_op_returns_none() {
304 let toml = r#"
305[[targets]]
306id = "tool:web-search"
307operations = [{ name = "invoke" }]
308"#;
309 let reg = SchemaRegistry::from_toml(toml).expect("parse");
310 assert!(reg.required_designations("nope", "invoke").is_none());
311 assert!(
312 reg.required_designations("tool:web-search", "nope")
313 .is_none()
314 );
315 let req = reg
316 .required_designations("tool:web-search", "invoke")
317 .expect("op exists");
318 assert!(req.is_empty());
319 }
320
321 #[test]
322 fn duplicate_target_in_same_file_errors() {
323 let toml = r#"
324[[targets]]
325id = "filesystem:source"
326operations = []
327
328[[targets]]
329id = "filesystem:source"
330operations = []
331"#;
332 let err = SchemaRegistry::from_toml(toml).expect_err("must reject duplicate");
333 match err {
334 SchemaError::DuplicateTarget { id, .. } => assert_eq!(id, "filesystem:source"),
335 other => panic!("wrong error variant: {other:?}"),
336 }
337 }
338
339 #[test]
340 fn duplicate_operation_within_target_errors() {
341 let toml = r#"
342[[targets]]
343id = "filesystem:source"
344operations = [
345 { name = "read" },
346 { name = "read" },
347]
348"#;
349 let err = SchemaRegistry::from_toml(toml).expect_err("must reject duplicate op");
350 match err {
351 SchemaError::DuplicateOperation { target, op } => {
352 assert_eq!(target, "filesystem:source");
353 assert_eq!(op, "read");
354 }
355 other => panic!("wrong error variant: {other:?}"),
356 }
357 }
358
359 #[test]
360 fn anchor_in_required_designations_is_rejected() {
361 let toml = r#"
362[[targets]]
363id = "filesystem:source"
364operations = [
365 { name = "read", required_designations = ["anchor", "path_prefix"] },
366]
367"#;
368 let err = SchemaRegistry::from_toml(toml).expect_err("must reject anchor");
369 match err {
370 SchemaError::ReservedLabel { label, .. } => assert_eq!(label, "anchor"),
371 other => panic!("wrong error variant: {other:?}"),
372 }
373 }
374
375 #[test]
376 fn facet_in_required_designations_is_rejected() {
377 let toml = r#"
378[[targets]]
379id = "tool:web-search"
380operations = [
381 { name = "invoke", required_designations = ["facet"] },
382]
383"#;
384 let err = SchemaRegistry::from_toml(toml).expect_err("must reject facet");
385 match err {
386 SchemaError::ReservedLabel { label, .. } => assert_eq!(label, "facet"),
387 other => panic!("wrong error variant: {other:?}"),
388 }
389 }
390
391 #[test]
392 fn unknown_top_level_sections_are_ignored() {
393 let toml = r#"
397[tool]
398name = "birthday_discord"
399type = "subprocess"
400command = "deno run birthday_discord.ts"
401
402[input_schema]
403type = "object"
404
405[[targets]]
406id = "tool:birthday-discord"
407operations = [{ name = "invoke" }]
408"#;
409 let reg = SchemaRegistry::from_toml(toml).expect("parse");
410 assert!(reg.get("tool:birthday-discord").is_some());
411 }
412
413 #[test]
414 fn add_target_programmatic_and_duplicate_detection() {
415 let mut reg = SchemaRegistry::new();
416 reg.add_target(TargetSchema {
417 id: "tool:web-search".to_string(),
418 operations: vec![OperationSchema {
419 name: "invoke".to_string(),
420 required_designations: vec!["query".to_string()],
421 }],
422 })
423 .expect("first add");
424
425 let dup = reg.add_target(TargetSchema {
426 id: "tool:web-search".to_string(),
427 operations: vec![],
428 });
429 assert!(matches!(dup, Err(SchemaError::DuplicateTarget { .. })));
430 }
431
432 #[test]
433 fn add_toml_composes_multiple_sources() {
434 let mut reg = SchemaRegistry::new();
435 reg.add_toml(
436 r#"
437[[targets]]
438id = "filesystem:source"
439operations = [{ name = "read", required_designations = ["path_prefix"] }]
440"#,
441 )
442 .expect("first");
443 reg.add_toml(
444 r#"
445[[targets]]
446id = "tool:discord-dm"
447operations = [{ name = "send", required_designations = ["user_id"] }]
448"#,
449 )
450 .expect("second");
451
452 assert_eq!(reg.targets().count(), 2);
453 assert!(reg.get("filesystem:source").is_some());
454 assert!(reg.get("tool:discord-dm").is_some());
455 }
456}