use std::collections::BTreeMap;
use std::fmt::{self, Write as _};
use std::path::{Component, Path, PathBuf};
use crate::name::{NameError, validate_name};
pub const SKILL_FILE: &str = "SKILL.md";
const MAX_DESCRIPTION: usize = 1024;
const RESERVED_FIELDS: [&str; 3] = ["name", "description", "metadata"];
pub const RECORD_KEYS: [&str; 3] = ["installed-by", "installed-version", "content-digest"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Skill {
name: String,
description: String,
body: String,
fields: Vec<(String, String)>,
metadata: Vec<(String, String)>,
files: BTreeMap<PathBuf, Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SkillError {
Name(NameError),
Description(usize),
Key(String),
Path(PathBuf),
Duplicate(String),
CaseCollision(PathBuf),
}
impl fmt::Display for SkillError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Name(error) => error.fmt(f),
Self::Description(len) => write!(
f,
"a description has 1 to {MAX_DESCRIPTION} characters, this one has {len}"
),
Self::Key(key) => write!(f, "'{key}' cannot be used as a frontmatter key here"),
Self::Duplicate(key) => write!(f, "'{key}' is given twice"),
Self::CaseCollision(path) => write!(
f,
"{} differs from another file only in letter case",
path.display()
),
Self::Path(path) => write!(
f,
"{} is not a relative path inside the skill",
path.display()
),
}
}
}
impl std::error::Error for SkillError {}
impl From<NameError> for SkillError {
fn from(error: NameError) -> Self {
Self::Name(error)
}
}
impl Skill {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
body: impl Into<String>,
) -> Result<Self, SkillError> {
let name = name.into();
validate_name(&name)?;
let description = description.into();
let len = description.chars().count();
if description.trim().is_empty() || len > MAX_DESCRIPTION {
return Err(SkillError::Description(len));
}
Ok(Self {
name,
description,
body: body.into(),
fields: Vec::new(),
metadata: Vec::new(),
files: BTreeMap::new(),
})
}
pub fn with_field(
mut self,
key: impl Into<String>,
value: impl Into<String>,
) -> Result<Self, SkillError> {
let key = key.into();
if !plain_key(&key) || RESERVED_FIELDS.contains(&key.as_str()) {
return Err(SkillError::Key(key));
}
if self.fields.iter().any(|(known, _)| *known == key) {
return Err(SkillError::Duplicate(key));
}
self.fields.push((key, value.into()));
Ok(self)
}
pub fn with_metadata(
mut self,
key: impl Into<String>,
value: impl Into<String>,
) -> Result<Self, SkillError> {
let key = key.into();
let plain = key.split('/').all(plain_key);
if !plain || RECORD_KEYS.contains(&key.as_str()) {
return Err(SkillError::Key(key));
}
if self.metadata.iter().any(|(known, _)| *known == key) {
return Err(SkillError::Duplicate(key));
}
self.metadata.push((key, value.into()));
Ok(self)
}
pub fn with_file(
mut self,
path: impl Into<PathBuf>,
content: impl Into<Vec<u8>>,
) -> Result<Self, SkillError> {
let path = path.into();
if !contained(&path) || path == Path::new(SKILL_FILE) {
return Err(SkillError::Path(path));
}
let wanted = folded(&path);
if wanted == folded_str(SKILL_FILE)
|| self.files.keys().any(|known| folded(known) == wanted)
{
return Err(SkillError::CaseCollision(path));
}
self.files.insert(path, content.into());
Ok(self)
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub(crate) fn files_with_record(
&self,
program: &str,
version: &str,
) -> BTreeMap<PathBuf, Vec<u8>> {
let mut out = self.files.clone();
out.insert(
PathBuf::from(SKILL_FILE),
self.render(program, version).into_bytes(),
);
out
}
fn render(&self, program: &str, version: &str) -> String {
let mut text = String::from("---\n");
let _ = writeln!(text, "name: {}", quote(&self.name));
let _ = writeln!(text, "description: {}", quote(&self.description));
for (key, value) in &self.fields {
let _ = writeln!(text, "{key}: {}", quote(value));
}
text.push_str("metadata:\n");
for (key, value) in &self.metadata {
let _ = writeln!(text, " {key}: {}", quote(value));
}
let _ = writeln!(text, " installed-by: {}", quote(program));
let _ = writeln!(text, " installed-version: {}", quote(version));
text.push_str(" content-digest: \"\"\n---\n");
text.push_str(&self.body);
if !self.body.ends_with('\n') {
text.push('\n');
}
text
}
}
fn plain_key(key: &str) -> bool {
key.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
fn folded(path: &Path) -> String {
folded_str(
&path
.components()
.map(|part| part.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
)
}
fn folded_str(text: &str) -> String {
text.to_lowercase()
}
pub fn contained(path: &Path) -> bool {
!path.as_os_str().is_empty()
&& path
.components()
.all(|part| matches!(part, Component::Normal(name) if !name.is_empty()))
&& !path.to_string_lossy().contains('\\')
}
pub fn quote(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for c in value.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ = write!(out, "\\u{:04x}", u32::from(c));
}
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Skill {
Skill::new("devserial", "Serial terminal.", "body\n").expect("skill")
}
#[test]
fn a_key_given_twice_is_refused() {
let once = base().with_field("license", "MIT").expect("first");
assert_eq!(
once.with_field("license", "GPL-3.0").err(),
Some(SkillError::Duplicate("license".into()))
);
let once = base()
.with_metadata("opencode/slash", "true")
.expect("first");
assert_eq!(
once.with_metadata("opencode/slash", "false").err(),
Some(SkillError::Duplicate("opencode/slash".into()))
);
}
#[test]
fn paths_differing_only_in_case_are_refused() {
let one = base().with_file("references/cli.md", "a").expect("first");
assert_eq!(
one.clone().with_file("References/CLI.md", "b").err(),
Some(SkillError::CaseCollision(PathBuf::from(
"References/CLI.md"
)))
);
assert_eq!(
one.clone().with_file("skill.md", "c").err(),
Some(SkillError::CaseCollision(PathBuf::from("skill.md")))
);
assert!(one.with_file("references/api.md", "d").is_ok());
}
}