Skip to main content

agentlink_domain/
provider.rs

1//! Providers are data, not code.
2//!
3//! A provider — an AI coding agent that agentlink knows how to serve — is
4//! described entirely by a TOML manifest. Adding support for a new agent is one
5//! file in `providers/` (see `crates/agentlink-domain/providers/`) plus a test
6//! fixture; no core change, no recompilation of
7//! anyone's mental model, and no Rust knowledge required.
8//!
9//! Manifests are parsed with `deny_unknown_fields` so a typo in a community
10//! contribution fails loudly at load time instead of silently doing nothing.
11
12use serde::Deserialize;
13
14use crate::model::{ResourceKind, Strategy};
15use crate::path::{PathError, RelPath};
16
17/// The manifest schema version this build understands.
18pub const SCHEMA_VERSION: u32 = 1;
19
20/// Placeholder replaced with the canonical path in an `import` template.
21const CANONICAL_PLACEHOLDER: &str = "{canonical}";
22
23/// Why a manifest was rejected.
24#[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/// A validated provider definition.
80#[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    /// The capability serving a given resource, if the provider declares one.
91    pub fn capability(&self, resource: ResourceKind) -> Option<&Capability> {
92        self.capabilities
93            .iter()
94            .find(|cap| cap.resource == resource)
95    }
96}
97
98/// How one provider obtains one resource.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Capability {
101    pub resource: ResourceKind,
102    pub strategy: Strategy,
103    /// Where this provider expects to find the resource.
104    pub path: RelPath,
105    pub note: Option<String>,
106    /// Used when the preferred strategy is impossible on the host.
107    pub fallback: Option<Fallback>,
108}
109
110/// A degraded but still correct way to serve a capability.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct Fallback {
113    pub strategy: Strategy,
114    pub template: String,
115}
116
117impl Capability {
118    /// Renders the `import` stub contents for this capability.
119    ///
120    /// Returns `None` when this capability has no import path at all. An inline
121    /// `template` on an `import` capability is normalised into `fallback` at
122    /// parse time, so there is exactly one place to read it from.
123    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    /// Whether this capability can degrade to an `import` stub when the host
133    /// cannot create the link it would prefer.
134    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// ---------------------------------------------------------------------------
142// Wire format
143// ---------------------------------------------------------------------------
144
145#[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
181/// Parses and validates a provider manifest.
182///
183/// `source_name` is used purely for diagnostics — typically the file name.
184/// `canonical` supplies the canonical path per resource so that `native`
185/// declarations can be checked against reality rather than trusted.
186pub 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        // A `native` declaration is a factual claim about the tool: "it reads the
229        // canonical path directly". If the declared path differs, the claim is
230        // wrong and we would silently write nothing while the user believes they
231        // are covered. Fail instead.
232        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        // Normalise both spellings of "there is an import stub" into one slot:
242        // a capability whose primary strategy is `import` carries its template
243        // inline, while a `link` capability carries it under `[capability.fallback]`.
244        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        // An import template that never mentions the canonical file produces an
265        // inert stub: the tool reads it, finds no reference, and the user
266        // silently gets nothing. Reject it at load time.
267        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        // Guards against a contributor marking a tool `native` at a path the tool
344        // does not actually read, which would silently produce a no-op.
345        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}