1use std::collections::BTreeMap;
12use std::fmt::{self, Write as _};
13use std::path::{Component, Path, PathBuf};
14
15use crate::name::{NameError, validate_name};
16
17pub const SKILL_FILE: &str = "SKILL.md";
19
20const MAX_DESCRIPTION: usize = 1024;
22
23const RESERVED_FIELDS: [&str; 3] = ["name", "description", "metadata"];
25
26pub const RECORD_KEYS: [&str; 3] = ["installed-by", "installed-version", "content-digest"];
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Skill {
32 name: String,
33 description: String,
34 body: String,
35 fields: Vec<(String, String)>,
36 metadata: Vec<(String, String)>,
37 files: BTreeMap<PathBuf, Vec<u8>>,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum SkillError {
44 Name(NameError),
46 Description(usize),
48 Key(String),
51 Path(PathBuf),
53 Duplicate(String),
56 CaseCollision(PathBuf),
60}
61
62impl fmt::Display for SkillError {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 match self {
65 Self::Name(error) => error.fmt(f),
66 Self::Description(len) => write!(
67 f,
68 "a description has 1 to {MAX_DESCRIPTION} characters, this one has {len}"
69 ),
70 Self::Key(key) => write!(f, "'{key}' cannot be used as a frontmatter key here"),
71 Self::Duplicate(key) => write!(f, "'{key}' is given twice"),
72 Self::CaseCollision(path) => write!(
73 f,
74 "{} differs from another file only in letter case",
75 path.display()
76 ),
77 Self::Path(path) => write!(
78 f,
79 "{} is not a relative path inside the skill",
80 path.display()
81 ),
82 }
83 }
84}
85
86impl std::error::Error for SkillError {}
87
88impl From<NameError> for SkillError {
89 fn from(error: NameError) -> Self {
90 Self::Name(error)
91 }
92}
93
94impl Skill {
95 pub fn new(
102 name: impl Into<String>,
103 description: impl Into<String>,
104 body: impl Into<String>,
105 ) -> Result<Self, SkillError> {
106 let name = name.into();
107 validate_name(&name)?;
108 let description = description.into();
109 let len = description.chars().count();
110 if description.trim().is_empty() || len > MAX_DESCRIPTION {
111 return Err(SkillError::Description(len));
112 }
113 Ok(Self {
114 name,
115 description,
116 body: body.into(),
117 fields: Vec::new(),
118 metadata: Vec::new(),
119 files: BTreeMap::new(),
120 })
121 }
122
123 pub fn with_field(
129 mut self,
130 key: impl Into<String>,
131 value: impl Into<String>,
132 ) -> Result<Self, SkillError> {
133 let key = key.into();
134 if !plain_key(&key) || RESERVED_FIELDS.contains(&key.as_str()) {
135 return Err(SkillError::Key(key));
136 }
137 if self.fields.iter().any(|(known, _)| *known == key) {
138 return Err(SkillError::Duplicate(key));
139 }
140 self.fields.push((key, value.into()));
141 Ok(self)
142 }
143
144 pub fn with_metadata(
150 mut self,
151 key: impl Into<String>,
152 value: impl Into<String>,
153 ) -> Result<Self, SkillError> {
154 let key = key.into();
155 let plain = key.split('/').all(plain_key);
157 if !plain || RECORD_KEYS.contains(&key.as_str()) {
158 return Err(SkillError::Key(key));
159 }
160 if self.metadata.iter().any(|(known, _)| *known == key) {
161 return Err(SkillError::Duplicate(key));
162 }
163 self.metadata.push((key, value.into()));
164 Ok(self)
165 }
166
167 pub fn with_file(
173 mut self,
174 path: impl Into<PathBuf>,
175 content: impl Into<Vec<u8>>,
176 ) -> Result<Self, SkillError> {
177 let path = path.into();
178 if !contained(&path) || path == Path::new(SKILL_FILE) {
179 return Err(SkillError::Path(path));
180 }
181 let wanted = folded(&path);
182 if wanted == folded_str(SKILL_FILE)
183 || self.files.keys().any(|known| folded(known) == wanted)
184 {
185 return Err(SkillError::CaseCollision(path));
186 }
187 self.files.insert(path, content.into());
188 Ok(self)
189 }
190
191 #[must_use]
193 pub fn name(&self) -> &str {
194 &self.name
195 }
196
197 pub(crate) fn files_with_record(
200 &self,
201 program: &str,
202 version: &str,
203 ) -> BTreeMap<PathBuf, Vec<u8>> {
204 let mut out = self.files.clone();
205 out.insert(
206 PathBuf::from(SKILL_FILE),
207 self.render(program, version).into_bytes(),
208 );
209 out
210 }
211
212 fn render(&self, program: &str, version: &str) -> String {
213 let mut text = String::from("---\n");
215 let _ = writeln!(text, "name: {}", quote(&self.name));
216 let _ = writeln!(text, "description: {}", quote(&self.description));
217 for (key, value) in &self.fields {
218 let _ = writeln!(text, "{key}: {}", quote(value));
219 }
220 text.push_str("metadata:\n");
221 for (key, value) in &self.metadata {
222 let _ = writeln!(text, " {key}: {}", quote(value));
223 }
224 let _ = writeln!(text, " installed-by: {}", quote(program));
225 let _ = writeln!(text, " installed-version: {}", quote(version));
226 text.push_str(" content-digest: \"\"\n---\n");
227 text.push_str(&self.body);
228 if !self.body.ends_with('\n') {
229 text.push('\n');
230 }
231 text
232 }
233}
234
235fn plain_key(key: &str) -> bool {
238 key.chars().next().is_some_and(|c| c.is_ascii_lowercase())
239 && key
240 .chars()
241 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
242}
243
244fn folded(path: &Path) -> String {
248 folded_str(
249 &path
250 .components()
251 .map(|part| part.as_os_str().to_string_lossy())
252 .collect::<Vec<_>>()
253 .join("/"),
254 )
255}
256
257fn folded_str(text: &str) -> String {
258 text.to_lowercase()
259}
260
261pub fn contained(path: &Path) -> bool {
262 !path.as_os_str().is_empty()
263 && path
264 .components()
265 .all(|part| matches!(part, Component::Normal(name) if !name.is_empty()))
266 && !path.to_string_lossy().contains('\\')
269}
270
271pub fn quote(value: &str) -> String {
276 let mut out = String::with_capacity(value.len() + 2);
277 out.push('"');
278 for c in value.chars() {
279 match c {
280 '"' => out.push_str("\\\""),
281 '\\' => out.push_str("\\\\"),
282 '\n' => out.push_str("\\n"),
283 '\r' => out.push_str("\\r"),
284 '\t' => out.push_str("\\t"),
285 c if c.is_control() => {
286 let _ = write!(out, "\\u{:04x}", u32::from(c));
287 }
288 c => out.push(c),
289 }
290 }
291 out.push('"');
292 out
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 fn base() -> Skill {
300 Skill::new("devserial", "Serial terminal.", "body\n").expect("skill")
301 }
302
303 #[test]
306 fn a_key_given_twice_is_refused() {
307 let once = base().with_field("license", "MIT").expect("first");
308 assert_eq!(
309 once.with_field("license", "GPL-3.0").err(),
310 Some(SkillError::Duplicate("license".into()))
311 );
312 let once = base()
313 .with_metadata("opencode/slash", "true")
314 .expect("first");
315 assert_eq!(
316 once.with_metadata("opencode/slash", "false").err(),
317 Some(SkillError::Duplicate("opencode/slash".into()))
318 );
319 }
320
321 #[test]
324 fn paths_differing_only_in_case_are_refused() {
325 let one = base().with_file("references/cli.md", "a").expect("first");
326 assert_eq!(
327 one.clone().with_file("References/CLI.md", "b").err(),
328 Some(SkillError::CaseCollision(PathBuf::from(
329 "References/CLI.md"
330 )))
331 );
332 assert_eq!(
333 one.clone().with_file("skill.md", "c").err(),
334 Some(SkillError::CaseCollision(PathBuf::from("skill.md")))
335 );
336 assert!(one.with_file("references/api.md", "d").is_ok());
337 }
338}