1use serde::Deserialize;
13
14use crate::model::{ResourceKind, Strategy};
15use crate::path::{PathError, RelPath};
16
17pub const SCHEMA_VERSION: u32 = 1;
19
20const CANONICAL_PLACEHOLDER: &str = "{canonical}";
22
23#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
25pub enum ManifestError {
26 #[error("{source_name}: {message}")]
27 Syntax {
28 source_name: String,
29 message: String,
30 },
31
32 #[error(
33 "{source_name}: unsupported schema version {found} (this build supports {SCHEMA_VERSION})"
34 )]
35 Schema { source_name: String, found: u32 },
36
37 #[error("{source_name}: provider id `{id}` must be lowercase alphanumeric with dashes")]
38 InvalidId { source_name: String, id: String },
39
40 #[error("{source_name}: invalid path for `{resource}`: {source}")]
41 InvalidPath {
42 source_name: String,
43 resource: ResourceKind,
44 #[source]
45 source: PathError,
46 },
47
48 #[error("{source_name}: declares `{resource}` more than once")]
49 DuplicateResource {
50 source_name: String,
51 resource: ResourceKind,
52 },
53
54 #[error(
55 "{source_name}: `{resource}` is declared `native` at `{declared}`, but the canonical path is `{canonical}`"
56 )]
57 NativeMismatch {
58 source_name: String,
59 resource: ResourceKind,
60 declared: RelPath,
61 canonical: RelPath,
62 },
63
64 #[error(
65 "{source_name}: `{resource}` uses `import` but its template omits `{CANONICAL_PLACEHOLDER}`"
66 )]
67 TemplateMissingPlaceholder {
68 source_name: String,
69 resource: ResourceKind,
70 },
71
72 #[error("{source_name}: `{resource}` declares a `native` fallback, which is never meaningful")]
73 NativeFallback {
74 source_name: String,
75 resource: ResourceKind,
76 },
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Provider {
82 pub id: String,
83 pub name: String,
84 pub homepage: Option<String>,
85 pub docs: Option<String>,
86 pub capabilities: Vec<Capability>,
87}
88
89impl Provider {
90 pub fn capability(&self, resource: ResourceKind) -> Option<&Capability> {
92 self.capabilities
93 .iter()
94 .find(|cap| cap.resource == resource)
95 }
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Capability {
101 pub resource: ResourceKind,
102 pub strategy: Strategy,
103 pub path: RelPath,
105 pub note: Option<String>,
106 pub fallback: Option<Fallback>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Fallback {
113 pub strategy: Strategy,
114 pub template: String,
115}
116
117impl Capability {
118 pub fn import_body(&self, canonical: &RelPath) -> Option<String> {
124 let fallback = self
125 .fallback
126 .as_ref()
127 .filter(|f| f.strategy == Strategy::Import)?;
128 let target = canonical.relative_to_dir(self.path.parent().as_ref());
129 Some(fallback.template.replace(CANONICAL_PLACEHOLDER, &target))
130 }
131
132 pub fn has_import_fallback(&self) -> bool {
135 self.fallback
136 .as_ref()
137 .is_some_and(|f| f.strategy == Strategy::Import)
138 }
139}
140
141#[derive(Debug, Deserialize)]
146#[serde(deny_unknown_fields)]
147struct RawManifest {
148 schema: u32,
149 id: String,
150 name: String,
151 #[serde(default)]
152 homepage: Option<String>,
153 #[serde(default)]
154 docs: Option<String>,
155 #[serde(default, rename = "capability")]
156 capabilities: Vec<RawCapability>,
157}
158
159#[derive(Debug, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct RawCapability {
162 resource: ResourceKind,
163 strategy: Strategy,
164 path: String,
165 #[serde(default)]
166 note: Option<String>,
167 #[serde(default)]
168 template: Option<String>,
169 #[serde(default)]
170 fallback: Option<RawFallback>,
171}
172
173#[derive(Debug, Deserialize)]
174#[serde(deny_unknown_fields)]
175struct RawFallback {
176 strategy: Strategy,
177 #[serde(default)]
178 template: Option<String>,
179}
180
181pub fn parse(
187 source_name: &str,
188 toml_text: &str,
189 canonical: impl Fn(ResourceKind) -> RelPath,
190) -> Result<Provider, ManifestError> {
191 let raw: RawManifest = toml::from_str(toml_text).map_err(|err| ManifestError::Syntax {
192 source_name: source_name.to_string(),
193 message: err.to_string(),
194 })?;
195
196 if raw.schema != SCHEMA_VERSION {
197 return Err(ManifestError::Schema {
198 source_name: source_name.to_string(),
199 found: raw.schema,
200 });
201 }
202 if !is_valid_id(&raw.id) {
203 return Err(ManifestError::InvalidId {
204 source_name: source_name.to_string(),
205 id: raw.id,
206 });
207 }
208
209 let mut capabilities: Vec<Capability> = Vec::with_capacity(raw.capabilities.len());
210 for cap in raw.capabilities {
211 if capabilities
212 .iter()
213 .any(|seen| seen.resource == cap.resource)
214 {
215 return Err(ManifestError::DuplicateResource {
216 source_name: source_name.to_string(),
217 resource: cap.resource,
218 });
219 }
220
221 let path = RelPath::new(&cap.path).map_err(|source| ManifestError::InvalidPath {
222 source_name: source_name.to_string(),
223 resource: cap.resource,
224 source,
225 })?;
226 let canonical_path = canonical(cap.resource);
227
228 if cap.strategy == Strategy::Native && path != canonical_path {
233 return Err(ManifestError::NativeMismatch {
234 source_name: source_name.to_string(),
235 resource: cap.resource,
236 declared: path,
237 canonical: canonical_path,
238 });
239 }
240
241 let fallback = match (cap.strategy, cap.template, cap.fallback) {
245 (Strategy::Import, template, _) => Some(Fallback {
246 strategy: Strategy::Import,
247 template: template.unwrap_or_default(),
248 }),
249 (_, _, Some(raw_fallback)) => {
250 if raw_fallback.strategy == Strategy::Native {
251 return Err(ManifestError::NativeFallback {
252 source_name: source_name.to_string(),
253 resource: cap.resource,
254 });
255 }
256 Some(Fallback {
257 strategy: raw_fallback.strategy,
258 template: raw_fallback.template.unwrap_or_default(),
259 })
260 }
261 (_, _, None) => None,
262 };
263
264 if let Some(f) = &fallback
268 && f.strategy == Strategy::Import
269 && !f.template.contains(CANONICAL_PLACEHOLDER)
270 {
271 return Err(ManifestError::TemplateMissingPlaceholder {
272 source_name: source_name.to_string(),
273 resource: cap.resource,
274 });
275 }
276
277 capabilities.push(Capability {
278 resource: cap.resource,
279 strategy: cap.strategy,
280 path,
281 note: cap.note,
282 fallback,
283 });
284 }
285
286 capabilities.sort_by_key(|cap| cap.resource);
287
288 Ok(Provider {
289 id: raw.id,
290 name: raw.name,
291 homepage: raw.homepage,
292 docs: raw.docs,
293 capabilities,
294 })
295}
296
297fn is_valid_id(id: &str) -> bool {
298 !id.is_empty()
299 && id.starts_with(|c: char| c.is_ascii_lowercase())
300 && id.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit())
301 && id
302 .chars()
303 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
304 && !id.contains("--")
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::layout::Layout;
311
312 fn canonical() -> impl Fn(ResourceKind) -> RelPath {
313 let layout = Layout::default();
314 move |kind| layout.canonical(kind).clone()
315 }
316
317 fn parse_ok(text: &str) -> Provider {
318 parse("test.toml", text, canonical()).expect("manifest should parse")
319 }
320
321 #[test]
322 fn parses_a_link_capability() {
323 let provider = parse_ok(
324 r#"
325 schema = 1
326 id = "claude-code"
327 name = "Claude Code"
328
329 [[capability]]
330 resource = "skills"
331 strategy = "link"
332 path = ".claude/skills"
333 "#,
334 );
335 assert_eq!(provider.id, "claude-code");
336 let cap = provider.capability(ResourceKind::Skills).unwrap();
337 assert_eq!(cap.strategy, Strategy::Link);
338 assert_eq!(cap.path.as_str(), ".claude/skills");
339 }
340
341 #[test]
342 fn rejects_a_native_claim_that_does_not_match_the_canonical_path() {
343 let err = parse(
346 "bogus.toml",
347 r#"
348 schema = 1
349 id = "bogus"
350 name = "Bogus"
351
352 [[capability]]
353 resource = "skills"
354 strategy = "native"
355 path = ".bogus/skills"
356 "#,
357 canonical(),
358 )
359 .unwrap_err();
360 assert!(
361 matches!(err, ManifestError::NativeMismatch { .. }),
362 "got {err:?}"
363 );
364 }
365
366 #[test]
367 fn rejects_paths_escaping_the_workspace() {
368 let err = parse(
369 "evil.toml",
370 r#"
371 schema = 1
372 id = "evil"
373 name = "Evil"
374
375 [[capability]]
376 resource = "skills"
377 strategy = "link"
378 path = "../../../.ssh"
379 "#,
380 canonical(),
381 )
382 .unwrap_err();
383 assert!(
384 matches!(err, ManifestError::InvalidPath { .. }),
385 "got {err:?}"
386 );
387 }
388
389 #[test]
390 fn rejects_unknown_fields() {
391 let err = parse(
392 "typo.toml",
393 r#"
394 schema = 1
395 id = "typo"
396 name = "Typo"
397
398 [[capability]]
399 resource = "skills"
400 strategy = "link"
401 pathh = ".typo/skills"
402 "#,
403 canonical(),
404 )
405 .unwrap_err();
406 assert!(matches!(err, ManifestError::Syntax { .. }), "got {err:?}");
407 }
408
409 #[test]
410 fn rejects_import_templates_that_ignore_the_canonical_path() {
411 let err = parse(
412 "inert.toml",
413 r#"
414 schema = 1
415 id = "inert"
416 name = "Inert"
417
418 [[capability]]
419 resource = "instructions"
420 strategy = "link"
421 path = "INERT.md"
422
423 [capability.fallback]
424 strategy = "import"
425 template = "read the other file\n"
426 "#,
427 canonical(),
428 )
429 .unwrap_err();
430 assert!(
431 matches!(err, ManifestError::TemplateMissingPlaceholder { .. }),
432 "got {err:?}"
433 );
434 }
435
436 #[test]
437 fn import_body_resolves_the_canonical_path_relative_to_the_stub() {
438 let provider = parse_ok(
439 r#"
440 schema = 1
441 id = "claude-code"
442 name = "Claude Code"
443
444 [[capability]]
445 resource = "instructions"
446 strategy = "link"
447 path = "CLAUDE.md"
448
449 [capability.fallback]
450 strategy = "import"
451 template = "@{canonical}\n"
452 "#,
453 );
454 let cap = provider.capability(ResourceKind::Instructions).unwrap();
455 let layout = Layout::default();
456 assert_eq!(
457 cap.import_body(layout.canonical(ResourceKind::Instructions)),
458 Some("@AGENTS.md\n".to_string())
459 );
460 }
461
462 #[test]
463 fn import_body_walks_up_from_nested_stubs() {
464 let provider = parse_ok(
465 r#"
466 schema = 1
467 id = "nested"
468 name = "Nested"
469
470 [[capability]]
471 resource = "instructions"
472 strategy = "import"
473 path = ".config/nested/RULES.md"
474 template = "See {canonical}\n"
475 "#,
476 );
477 let cap = provider.capability(ResourceKind::Instructions).unwrap();
478 let layout = Layout::default();
479 assert_eq!(
480 cap.import_body(layout.canonical(ResourceKind::Instructions)),
481 Some("See ../../AGENTS.md\n".to_string())
482 );
483 }
484
485 #[test]
486 fn rejects_duplicate_resources() {
487 let err = parse(
488 "dupe.toml",
489 r#"
490 schema = 1
491 id = "dupe"
492 name = "Dupe"
493
494 [[capability]]
495 resource = "skills"
496 strategy = "link"
497 path = ".a/skills"
498
499 [[capability]]
500 resource = "skills"
501 strategy = "link"
502 path = ".b/skills"
503 "#,
504 canonical(),
505 )
506 .unwrap_err();
507 assert!(
508 matches!(err, ManifestError::DuplicateResource { .. }),
509 "got {err:?}"
510 );
511 }
512
513 #[test]
514 fn rejects_future_schema_versions() {
515 let err = parse(
516 "future.toml",
517 r#"
518 schema = 999
519 id = "future"
520 name = "Future"
521 "#,
522 canonical(),
523 )
524 .unwrap_err();
525 assert!(
526 matches!(err, ManifestError::Schema { found: 999, .. }),
527 "got {err:?}"
528 );
529 }
530
531 #[test]
532 fn validates_provider_ids() {
533 assert!(is_valid_id("claude-code"));
534 assert!(is_valid_id("codex"));
535 assert!(is_valid_id("gemini2"));
536 assert!(!is_valid_id(""));
537 assert!(!is_valid_id("-leading"));
538 assert!(!is_valid_id("trailing-"));
539 assert!(!is_valid_id("Upper"));
540 assert!(!is_valid_id("double--dash"));
541 assert!(!is_valid_id("under_score"));
542 }
543}