use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
pub use crate::CapabilityRef as AgentCapabilityConfig;
#[cfg(feature = "openapi")]
#[derive(serde::Serialize, serde::Deserialize, utoipa::ToSchema)]
#[schema(as = AgentCapabilityConfig)]
#[allow(dead_code)]
pub(crate) struct AgentCapabilityConfigSchema {
#[serde(rename = "ref")]
#[schema(value_type = String)]
pub capability_ref: String,
#[serde(default)]
pub config: serde_json::Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CapabilityStatus {
Available,
#[serde(alias = "comingsoon")]
ComingSoon,
Deprecated,
NeedsIdentity,
Retired,
}
impl CapabilityStatus {
pub fn is_active(self) -> bool {
matches!(self, Self::Available | Self::Deprecated)
}
pub fn is_listed(self) -> bool {
!matches!(self, Self::Retired)
}
}
impl std::fmt::Display for CapabilityStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CapabilityStatus::Available => write!(f, "available"),
CapabilityStatus::ComingSoon => write!(f, "coming_soon"),
CapabilityStatus::Deprecated => write!(f, "deprecated"),
CapabilityStatus::NeedsIdentity => write!(f, "needs_identity"),
CapabilityStatus::Retired => write!(f, "retired"),
}
}
}
#[derive(Debug, Clone)]
pub struct VirtualFileTree {
files: HashMap<String, VirtualFile>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VirtualFile {
pub content: Vec<u8>,
pub is_directory: bool,
}
impl VirtualFileTree {
pub fn new() -> Self {
Self {
files: HashMap::new(),
}
}
pub fn insert_text(&mut self, path: impl Into<String>, content: impl Into<String>) {
let path = path.into();
self.ensure_parent_dirs(&path);
self.files.insert(
path,
VirtualFile {
content: content.into().into_bytes(),
is_directory: false,
},
);
}
pub fn insert_directory(&mut self, path: impl Into<String>) {
self.files.insert(
path.into(),
VirtualFile {
content: Vec::new(),
is_directory: true,
},
);
}
pub fn get(&self, path: &str) -> Option<&VirtualFile> {
self.files.get(path)
}
pub fn list_directory(&self, dir_path: &str) -> Vec<(String, &VirtualFile)> {
let prefix = if dir_path == "/" {
"/".to_string()
} else {
format!("{dir_path}/")
};
self.files
.iter()
.filter(|(p, _)| {
if let Some(rest) = p.strip_prefix(&prefix) {
!rest.is_empty() && !rest.contains('/')
} else {
false
}
})
.map(|(p, f)| (p.clone(), f))
.collect()
}
pub fn all_files(&self) -> impl Iterator<Item = (&str, &VirtualFile)> {
self.files
.iter()
.filter(|(_, f)| !f.is_directory)
.map(|(p, f)| (p.as_str(), f))
}
pub fn len(&self) -> usize {
self.files.len()
}
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
fn ensure_parent_dirs(&mut self, path: &str) {
let parts: Vec<&str> = path.trim_start_matches('/').split('/').collect();
let mut current = String::new();
for part in &parts[..parts.len().saturating_sub(1)] {
current = format!("{current}/{part}");
self.files.entry(current.clone()).or_insert(VirtualFile {
content: Vec::new(),
is_directory: true,
});
}
}
}
impl Default for VirtualFileTree {
fn default() -> Self {
Self::new()
}
}
impl PartialEq for VirtualFileTree {
fn eq(&self, other: &Self) -> bool {
self.files == other.files
}
}
impl Eq for VirtualFileTree {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum MountAccess {
#[default]
ReadOnly,
ReadWrite,
}
impl std::fmt::Display for MountAccess {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MountAccess::ReadOnly => write!(f, "readonly"),
MountAccess::ReadWrite => write!(f, "readwrite"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MountSource {
InlineFile {
content: String,
encoding: String,
},
InlineDirectory {
entries: HashMap<String, MountEntry>,
},
Virtual { tree: Arc<VirtualFileTree> },
}
impl MountSource {
pub fn text_file(content: impl Into<String>) -> Self {
Self::InlineFile {
content: content.into(),
encoding: "text".to_string(),
}
}
pub fn binary_file(content: impl Into<String>) -> Self {
Self::InlineFile {
content: content.into(),
encoding: "base64".to_string(),
}
}
pub fn directory(entries: HashMap<String, MountEntry>) -> Self {
Self::InlineDirectory { entries }
}
pub fn virtual_tree(tree: Arc<VirtualFileTree>) -> Self {
Self::Virtual { tree }
}
pub fn is_directory(&self) -> bool {
matches!(self, Self::InlineDirectory { .. } | Self::Virtual { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountEntry {
pub source: MountSource,
}
impl MountEntry {
pub fn new(source: MountSource) -> Self {
Self { source }
}
pub fn text_file(content: impl Into<String>) -> Self {
Self::new(MountSource::text_file(content))
}
pub fn directory(entries: HashMap<String, MountEntry>) -> Self {
Self::new(MountSource::directory(entries))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountPoint {
pub path: String,
pub access: MountAccess,
pub source: MountSource,
pub capability_id: String,
}
impl MountPoint {
pub fn new(
path: impl Into<String>,
access: MountAccess,
source: MountSource,
capability_id: impl Into<String>,
) -> Self {
Self {
path: path.into(),
access,
source,
capability_id: capability_id.into(),
}
}
pub fn readonly(
path: impl Into<String>,
source: MountSource,
capability_id: impl Into<String>,
) -> Self {
Self::new(path, MountAccess::ReadOnly, source, capability_id)
}
pub fn readwrite(
path: impl Into<String>,
source: MountSource,
capability_id: impl Into<String>,
) -> Self {
Self::new(path, MountAccess::ReadWrite, source, capability_id)
}
pub fn is_readonly(&self) -> bool {
self.access == MountAccess::ReadOnly
}
}
#[derive(Debug, Default)]
pub struct MountDirectoryBuilder {
entries: HashMap<String, MountEntry>,
}
impl MountDirectoryBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn file(mut self, name: impl Into<String>, content: impl Into<String>) -> Self {
self.entries
.insert(name.into(), MountEntry::text_file(content));
self
}
pub fn dir(mut self, name: impl Into<String>, builder: MountDirectoryBuilder) -> Self {
self.entries
.insert(name.into(), MountEntry::directory(builder.entries));
self
}
pub fn build(self) -> MountSource {
MountSource::directory(self.entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mount_access_preserves_readonly_default_and_wire_values() {
assert_eq!(MountAccess::default(), MountAccess::ReadOnly);
for (access, text) in [
(MountAccess::ReadOnly, "readonly"),
(MountAccess::ReadWrite, "readwrite"),
] {
assert_eq!(access.to_string(), text);
assert_eq!(
serde_json::to_value(access).unwrap(),
serde_json::json!(text)
);
assert_eq!(
serde_json::from_value::<MountAccess>(serde_json::json!(text)).unwrap(),
access
);
}
}
#[test]
fn file_sources_preserve_content_and_encoding() {
for (source, expected_content, expected_encoding) in [
(
MountSource::text_file("Hello, World!"),
"Hello, World!",
"text",
),
(MountSource::binary_file("SGVsbG8="), "SGVsbG8=", "base64"),
] {
assert!(!source.is_directory());
assert_eq!(
source,
MountSource::InlineFile {
content: expected_content.into(),
encoding: expected_encoding.into(),
}
);
}
}
#[test]
fn mount_point_constructors_preserve_source_and_access() {
let source = MountSource::text_file("mounted content");
for (mount, access) in [
(
MountPoint::new(
"/samples",
MountAccess::ReadWrite,
source.clone(),
"sample_data",
),
MountAccess::ReadWrite,
),
(
MountPoint::readonly("/samples", source.clone(), "sample_data"),
MountAccess::ReadOnly,
),
(
MountPoint::readwrite("/samples", source.clone(), "sample_data"),
MountAccess::ReadWrite,
),
] {
assert_eq!(mount.path, "/samples");
assert_eq!(mount.capability_id, "sample_data");
assert_eq!(mount.source, source);
assert_eq!(mount.access, access);
assert_eq!(mount.is_readonly(), access == MountAccess::ReadOnly);
}
}
#[test]
fn mount_directory_builder_preserves_nested_content() {
let source = MountDirectoryBuilder::new()
.file("readme.txt", "Hello")
.file("config.json", "{}")
.dir(
"nested",
MountDirectoryBuilder::new().file("inner.txt", "Nested content"),
)
.build();
let text = |content: &str| MountEntry {
source: MountSource::InlineFile {
content: content.into(),
encoding: "text".into(),
},
};
let entries = HashMap::from([
("readme.txt".into(), text("Hello")),
("config.json".into(), text("{}")),
(
"nested".into(),
MountEntry {
source: MountSource::InlineDirectory {
entries: HashMap::from([("inner.txt".into(), text("Nested content"))]),
},
},
),
]);
assert!(source.is_directory());
assert_eq!(
source,
MountSource::InlineDirectory {
entries: entries.clone()
}
);
assert_eq!(MountSource::directory(entries), source);
}
#[test]
fn virtual_tree_lists_direct_children_and_preserves_file_content() {
let mut tree = VirtualFileTree::new();
assert!(tree.is_empty());
tree.insert_text("/docs/nested/readme.md", "First");
tree.insert_text("/docs/nested/readme.md", "Updated");
tree.insert_text("/docs/index.md", "Index");
tree.insert_directory("/empty");
assert_eq!(
tree.get("/docs/nested/readme.md"),
Some(&VirtualFile {
content: b"Updated".to_vec(),
is_directory: false
})
);
assert!(tree.get("/docs").unwrap().is_directory);
assert!(tree.get("/missing").is_none());
let mut direct: Vec<_> = tree
.list_directory("/docs")
.into_iter()
.map(|(path, _)| path)
.collect();
direct.sort();
assert_eq!(direct, ["/docs/index.md", "/docs/nested"]);
let mut files: Vec<_> = tree.all_files().map(|(path, _)| path).collect();
files.sort();
assert_eq!(files, ["/docs/index.md", "/docs/nested/readme.md"]);
assert_eq!(tree.len(), 5);
assert!(!tree.is_empty());
assert!(MountSource::virtual_tree(Arc::new(tree)).is_directory());
}
#[test]
fn capability_status_wire_format_is_snake_case() {
for (status, wire) in [
(CapabilityStatus::Available, "available"),
(CapabilityStatus::ComingSoon, "coming_soon"),
(CapabilityStatus::Deprecated, "deprecated"),
(CapabilityStatus::NeedsIdentity, "needs_identity"),
(CapabilityStatus::Retired, "retired"),
] {
assert_eq!(
serde_json::to_value(status).unwrap(),
serde_json::json!(wire)
);
assert_eq!(
serde_json::from_value::<CapabilityStatus>(serde_json::json!(wire)).unwrap(),
status
);
assert_eq!(status.to_string(), wire);
}
assert_eq!(
serde_json::from_value::<CapabilityStatus>(serde_json::json!("comingsoon")).unwrap(),
CapabilityStatus::ComingSoon
);
}
#[test]
fn only_available_and_deprecated_contribute_at_runtime() {
assert!(CapabilityStatus::Available.is_active());
assert!(CapabilityStatus::Deprecated.is_active());
assert!(!CapabilityStatus::ComingSoon.is_active());
assert!(!CapabilityStatus::NeedsIdentity.is_active());
assert!(!CapabilityStatus::Retired.is_active());
assert!(CapabilityStatus::Available.is_listed());
assert!(CapabilityStatus::ComingSoon.is_listed());
assert!(CapabilityStatus::Deprecated.is_listed());
assert!(CapabilityStatus::NeedsIdentity.is_listed());
assert!(!CapabilityStatus::Retired.is_listed());
}
}