use std::fmt;
use std::path::Path;
use semver::Version;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Copy, Debug)]
pub struct AssetLibrary {
agents: &'static [AgentDefinition],
skills: &'static [SkillDefinition],
commands: &'static [CommandDefinition],
teams: &'static [TeamDefinition],
}
impl AssetLibrary {
pub fn new(
agents: &'static [AgentDefinition],
skills: &'static [SkillDefinition],
commands: &'static [CommandDefinition],
teams: &'static [TeamDefinition],
) -> Self {
Self {
agents,
skills,
commands,
teams,
}
}
pub fn agents_for(self) -> impl Iterator<Item = &'static AgentDefinition> {
self.agents.iter()
}
pub fn skills_for(self) -> impl Iterator<Item = &'static SkillDefinition> {
self.skills.iter()
}
pub fn commands_for(self) -> impl Iterator<Item = &'static CommandDefinition> {
self.commands.iter()
}
pub fn teams_for(self) -> impl Iterator<Item = &'static TeamDefinition> {
self.teams.iter()
}
}
#[derive(Debug, Clone)]
pub struct AgentAssetDefinition<Id> {
pub id: Id,
pub version: Version,
pub role: AgentRole,
pub name: &'static str,
pub job_title: &'static str,
pub description: &'static str,
pub body: &'static str,
}
pub type AgentDefinition = AgentAssetDefinition<AgentId>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AgentRole {
Leader,
Follower,
}
#[derive(Debug, Clone)]
pub struct TeamDefinition {
pub leader: AgentId,
pub followers: Vec<AgentId>,
pub skills: Vec<SkillId>,
pub version: Version,
}
#[derive(Debug, Clone)]
pub struct TeamSummary {
pub leader: AgentId,
pub followers: Vec<AgentId>,
pub skills: Vec<SkillId>,
pub version: Version,
}
#[derive(Debug, Clone)]
pub struct SkillBundleFile {
pub relative_path: &'static str,
pub contents: &'static str,
}
impl SkillBundleFile {
pub fn new(relative_path: &'static str, contents: &'static str) -> Result<Self, Error> {
validate_relative_path(relative_path)?;
Ok(Self {
relative_path,
contents,
})
}
}
#[derive(Debug, Clone)]
pub struct SkillDefinition {
pub id: SkillId,
pub version: Version,
pub name: &'static str,
pub description: &'static str,
pub bundle: Vec<SkillBundleFile>,
}
impl SkillDefinition {
pub fn new(
id: SkillId,
version: Version,
name: &'static str,
description: &'static str,
bundle: Vec<SkillBundleFile>,
) -> Result<Self, Error> {
let definition = Self {
id,
version,
name,
description,
bundle,
};
definition.validate()?;
Ok(definition)
}
pub fn validate(&self) -> Result<(), Error> {
for file in &self.bundle {
validate_relative_path(file.relative_path)?;
}
if self.bundle.first().map(|file| file.relative_path) != Some("SKILL.md") {
return Err(Error::InvalidAssetId {
asset_id: self
.bundle
.first()
.map_or_else(String::new, |file| file.relative_path.to_owned()),
violation: AssetIdViolation::Empty,
});
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CommandDefinition {
pub id: CommandId,
pub version: Version,
pub name: &'static str,
pub description: &'static str,
pub body: &'static str,
}
#[derive(Debug, Clone)]
pub struct AgentSummary {
pub id: AgentId,
pub version: Version,
pub role: AgentRole,
pub name: &'static str,
pub job_title: &'static str,
pub description: &'static str,
}
#[derive(Debug, Clone)]
pub struct SkillSummary {
pub id: SkillId,
pub version: Version,
pub name: &'static str,
pub description: &'static str,
}
#[derive(Debug, Clone)]
pub struct CommandSummary {
pub id: CommandId,
pub version: Version,
pub name: &'static str,
pub description: &'static str,
}
macro_rules! asset_id {
($name:ident, $description:literal) => {
#[doc = $description]
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Result<Self, Error> {
Self::try_from(value.into())
}
#[must_use]
pub fn normalized_destination(&self) -> &str {
&self.0
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.0)
}
}
impl TryFrom<String> for $name {
type Error = Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
let violation = if value.is_empty() {
Some(AssetIdViolation::Empty)
} else if value.chars().any(char::is_control) {
Some(AssetIdViolation::ControlCharacter)
} else if value == "." || value == ".." {
Some(AssetIdViolation::DotSegment)
} else if Path::new(&value).is_absolute() {
Some(AssetIdViolation::AbsoluteOrRooted)
} else if {
let bytes = value.as_bytes();
bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
} {
Some(AssetIdViolation::PathPrefix)
} else if value.contains('/') || value.contains('\\') {
Some(AssetIdViolation::Separator)
} else {
None
};
if let Some(violation) = violation {
return Err(Error::InvalidAssetId {
asset_id: value,
violation,
});
}
Ok(Self(value))
}
}
impl TryFrom<&str> for $name {
type Error = Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
Self::try_from(value.to_owned())
}
}
};
}
asset_id!(AgentId, "A safe canonical agent destination identity.");
asset_id!(SkillId, "A safe canonical skill destination identity.");
asset_id!(CommandId, "A safe canonical command destination identity.");
fn validate_relative_path(relative_path: &str) -> Result<(), Error> {
let bytes = relative_path.as_bytes();
let has_path_prefix = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
let invalid = if relative_path.is_empty() {
Some(AssetIdViolation::Empty)
} else if relative_path.chars().any(char::is_control) {
Some(AssetIdViolation::ControlCharacter)
} else if Path::new(relative_path).is_absolute() {
Some(AssetIdViolation::AbsoluteOrRooted)
} else if has_path_prefix {
Some(AssetIdViolation::PathPrefix)
} else if relative_path.contains('\\') || relative_path.split('/').any(str::is_empty) {
Some(AssetIdViolation::Separator)
} else if relative_path
.split('/')
.any(|segment| segment == "." || segment == "..")
{
Some(AssetIdViolation::DotSegment)
} else {
None
};
invalid.map_or(Ok(()), |violation| {
Err(Error::InvalidAssetId {
asset_id: relative_path.to_owned(),
violation,
})
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AssetIdViolation {
Empty,
Separator,
DotSegment,
AbsoluteOrRooted,
PathPrefix,
ControlCharacter,
}
impl fmt::Display for AssetIdViolation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let reason = match self {
Self::Empty => "asset ID must not be empty",
Self::Separator => "asset ID must be a single destination segment",
Self::DotSegment => "asset ID must not be `.` or `..`",
Self::AbsoluteOrRooted => "asset ID must not be absolute or rooted",
Self::PathPrefix => "asset ID must not contain a platform path prefix",
Self::ControlCharacter => "asset ID must not contain control characters",
};
formatter.write_str(reason)
}
}
#[derive(Debug, Error)]
pub enum Error {
#[error("invalid asset id {asset_id:?}: {violation}")]
InvalidAssetId {
asset_id: String,
violation: AssetIdViolation,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_invalid_asset_id<T>(result: Result<T, Error>, asset_id: &str, violation: AssetIdViolation) {
let error = match result {
Ok(_) => panic!("asset ID should be rejected"),
Err(error) => error,
};
assert!(matches!(
error,
Error::InvalidAssetId {
asset_id: invalid_id,
violation: actual_violation,
} if invalid_id == asset_id && actual_violation == violation
));
}
#[test]
fn asset_ids_accept_safe_single_segments_without_cosmetic_restrictions() {
let safe_id = "Agent name_42.@example";
assert_eq!(AgentId::new(safe_id).unwrap().normalized_destination(), safe_id);
assert_eq!(SkillId::try_from(safe_id).unwrap().as_ref(), safe_id);
assert_eq!(CommandId::try_from(safe_id.to_owned()).unwrap().to_string(), safe_id);
}
#[test]
fn asset_ids_reject_unsafe_destination_segments_with_typed_context() {
let cases = [
("", AssetIdViolation::Empty),
("/", AssetIdViolation::AbsoluteOrRooted),
("agent\\id", AssetIdViolation::Separator),
(".", AssetIdViolation::DotSegment),
("..", AssetIdViolation::DotSegment),
("/absolute", AssetIdViolation::AbsoluteOrRooted),
("C:prefix", AssetIdViolation::PathPrefix),
("agent\0id", AssetIdViolation::ControlCharacter),
("agent\nid", AssetIdViolation::ControlCharacter),
];
for (asset_id, violation) in cases {
assert_invalid_asset_id(AgentId::try_from(asset_id), asset_id, violation);
assert_invalid_asset_id(SkillId::try_from(asset_id), asset_id, violation);
assert_invalid_asset_id(CommandId::try_from(asset_id), asset_id, violation);
}
}
}