agent_skills/
frontmatter.rs

1//! Frontmatter type representing the YAML header of a SKILL.md file.
2
3use serde::{Deserialize, Serialize};
4
5use crate::allowed_tools::AllowedTools;
6use crate::compatibility::Compatibility;
7use crate::description::SkillDescription;
8use crate::metadata::Metadata;
9use crate::name::SkillName;
10
11/// The YAML frontmatter of a SKILL.md file.
12///
13/// Contains both required fields (name, description) and optional fields
14/// (license, compatibility, metadata, allowed-tools).
15///
16/// # Examples
17///
18/// ```
19/// use agent_skills::{Frontmatter, SkillName, SkillDescription};
20///
21/// let name = SkillName::new("my-skill").unwrap();
22/// let description = SkillDescription::new("Does something useful.").unwrap();
23///
24/// let frontmatter = Frontmatter::new(name, description);
25/// assert_eq!(frontmatter.name().as_str(), "my-skill");
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "kebab-case")]
29pub struct Frontmatter {
30    name: SkillName,
31    description: SkillDescription,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    license: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    compatibility: Option<Compatibility>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    metadata: Option<Metadata>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    allowed_tools: Option<AllowedTools>,
40}
41
42impl Frontmatter {
43    /// Creates a new frontmatter with required fields only.
44    ///
45    /// Use the builder for setting optional fields.
46    #[must_use]
47    pub const fn new(name: SkillName, description: SkillDescription) -> Self {
48        Self {
49            name,
50            description,
51            license: None,
52            compatibility: None,
53            metadata: None,
54            allowed_tools: None,
55        }
56    }
57
58    /// Returns a builder for constructing frontmatter with optional fields.
59    ///
60    /// # Examples
61    ///
62    /// ```
63    /// use agent_skills::{Frontmatter, SkillName, SkillDescription, Metadata};
64    ///
65    /// let name = SkillName::new("my-skill").unwrap();
66    /// let desc = SkillDescription::new("Does something.").unwrap();
67    ///
68    /// let frontmatter = Frontmatter::builder(name, desc)
69    ///     .license("MIT")
70    ///     .metadata(Metadata::from_pairs([("author", "test")]))
71    ///     .build();
72    ///
73    /// assert_eq!(frontmatter.license(), Some("MIT"));
74    /// ```
75    #[must_use]
76    pub const fn builder(name: SkillName, description: SkillDescription) -> FrontmatterBuilder {
77        FrontmatterBuilder::new(name, description)
78    }
79
80    /// Returns the skill name.
81    #[must_use]
82    pub const fn name(&self) -> &SkillName {
83        &self.name
84    }
85
86    /// Returns the skill description.
87    #[must_use]
88    pub const fn description(&self) -> &SkillDescription {
89        &self.description
90    }
91
92    /// Returns the license, if specified.
93    #[must_use]
94    pub fn license(&self) -> Option<&str> {
95        self.license.as_deref()
96    }
97
98    /// Returns the compatibility string, if specified.
99    #[must_use]
100    pub const fn compatibility(&self) -> Option<&Compatibility> {
101        self.compatibility.as_ref()
102    }
103
104    /// Returns the metadata, if specified.
105    #[must_use]
106    pub const fn metadata(&self) -> Option<&Metadata> {
107        self.metadata.as_ref()
108    }
109
110    /// Returns the allowed tools, if specified.
111    #[must_use]
112    pub const fn allowed_tools(&self) -> Option<&AllowedTools> {
113        self.allowed_tools.as_ref()
114    }
115}
116
117/// Builder for constructing [`Frontmatter`] with optional fields.
118#[derive(Debug, Clone)]
119pub struct FrontmatterBuilder {
120    name: SkillName,
121    description: SkillDescription,
122    license: Option<String>,
123    compatibility: Option<Compatibility>,
124    metadata: Option<Metadata>,
125    allowed_tools: Option<AllowedTools>,
126}
127
128impl FrontmatterBuilder {
129    /// Creates a new builder with required fields.
130    #[must_use]
131    pub const fn new(name: SkillName, description: SkillDescription) -> Self {
132        Self {
133            name,
134            description,
135            license: None,
136            compatibility: None,
137            metadata: None,
138            allowed_tools: None,
139        }
140    }
141
142    /// Sets the license.
143    #[must_use]
144    pub fn license(mut self, license: impl Into<String>) -> Self {
145        self.license = Some(license.into());
146        self
147    }
148
149    /// Sets the compatibility.
150    #[must_use]
151    pub fn compatibility(mut self, compat: Compatibility) -> Self {
152        self.compatibility = Some(compat);
153        self
154    }
155
156    /// Sets the metadata.
157    #[must_use]
158    pub fn metadata(mut self, metadata: Metadata) -> Self {
159        self.metadata = Some(metadata);
160        self
161    }
162
163    /// Sets the allowed tools.
164    #[must_use]
165    pub fn allowed_tools(mut self, tools: AllowedTools) -> Self {
166        self.allowed_tools = Some(tools);
167        self
168    }
169
170    /// Builds the frontmatter.
171    #[must_use]
172    pub fn build(self) -> Frontmatter {
173        Frontmatter {
174            name: self.name,
175            description: self.description,
176            license: self.license,
177            compatibility: self.compatibility,
178            metadata: self.metadata,
179            allowed_tools: self.allowed_tools,
180        }
181    }
182}
183
184#[cfg(test)]
185#[allow(clippy::unwrap_used, clippy::expect_used)]
186mod tests {
187    use super::*;
188
189    fn test_name() -> SkillName {
190        SkillName::new("test-skill").unwrap()
191    }
192
193    fn test_description() -> SkillDescription {
194        SkillDescription::new("A test skill.").unwrap()
195    }
196
197    #[test]
198    fn new_creates_frontmatter_with_required_fields() {
199        let fm = Frontmatter::new(test_name(), test_description());
200        assert_eq!(fm.name().as_str(), "test-skill");
201        assert_eq!(fm.description().as_str(), "A test skill.");
202        assert!(fm.license().is_none());
203        assert!(fm.compatibility().is_none());
204        assert!(fm.metadata().is_none());
205        assert!(fm.allowed_tools().is_none());
206    }
207
208    #[test]
209    fn builder_sets_license() {
210        let fm = Frontmatter::builder(test_name(), test_description())
211            .license("MIT")
212            .build();
213        assert_eq!(fm.license(), Some("MIT"));
214    }
215
216    #[test]
217    fn builder_sets_compatibility() {
218        let compat = Compatibility::new("Requires docker").unwrap();
219        let fm = Frontmatter::builder(test_name(), test_description())
220            .compatibility(compat)
221            .build();
222        assert!(fm.compatibility().is_some());
223        assert_eq!(fm.compatibility().unwrap().as_str(), "Requires docker");
224    }
225
226    #[test]
227    fn builder_sets_metadata() {
228        let metadata = Metadata::from_pairs([("author", "test")]);
229        let fm = Frontmatter::builder(test_name(), test_description())
230            .metadata(metadata)
231            .build();
232        assert!(fm.metadata().is_some());
233        assert_eq!(fm.metadata().unwrap().get("author"), Some("test"));
234    }
235
236    #[test]
237    fn builder_sets_allowed_tools() {
238        let tools = AllowedTools::new("Read Write");
239        let fm = Frontmatter::builder(test_name(), test_description())
240            .allowed_tools(tools)
241            .build();
242        assert!(fm.allowed_tools().is_some());
243        assert_eq!(fm.allowed_tools().unwrap().len(), 2);
244    }
245
246    #[test]
247    fn builder_chains_all_options() {
248        let compat = Compatibility::new("Requires git").unwrap();
249        let metadata = Metadata::from_pairs([("version", "1.0")]);
250        let tools = AllowedTools::new("Bash");
251
252        let fm = Frontmatter::builder(test_name(), test_description())
253            .license("Apache-2.0")
254            .compatibility(compat)
255            .metadata(metadata)
256            .allowed_tools(tools)
257            .build();
258
259        assert_eq!(fm.license(), Some("Apache-2.0"));
260        assert!(fm.compatibility().is_some());
261        assert!(fm.metadata().is_some());
262        assert!(fm.allowed_tools().is_some());
263    }
264}