use std::collections::HashMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use astrid_core::UplinkProfile;
mod capabilities;
mod topics;
pub use capabilities::CapabilitiesDef;
pub use topics::{PublishDef, SubscribeDef};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CapsuleManifest {
pub package: PackageDef,
#[serde(default, rename = "component")]
pub components: Vec<ComponentDef>,
#[serde(default, deserialize_with = "deserialize_imports_map")]
pub imports: ImportsMap,
#[serde(default, deserialize_with = "deserialize_exports_map")]
pub exports: ExportsMap,
#[serde(default, rename = "publish")]
pub publishes: HashMap<String, PublishDef>,
#[serde(default, rename = "subscribe")]
pub subscribes: HashMap<String, SubscribeDef>,
#[serde(default, rename = "tool")]
pub tools: Vec<ToolDef>,
#[serde(default)]
pub capabilities: CapabilitiesDef,
#[serde(default)]
pub env: HashMap<String, EnvDef>,
#[serde(default, rename = "context_file")]
pub context_files: Vec<ContextFileDef>,
#[serde(default, rename = "command")]
pub commands: Vec<CommandDef>,
#[serde(default, rename = "mcp_server")]
pub mcp_servers: Vec<McpServerDef>,
#[serde(default, rename = "skill")]
pub skills: Vec<SkillDef>,
#[serde(default, rename = "uplink")]
pub uplinks: Vec<UplinkDef>,
}
impl CapsuleManifest {
#[must_use]
pub fn has_imports(&self) -> bool {
self.imports.values().any(|ns| !ns.is_empty())
}
#[must_use]
pub fn has_exports(&self) -> bool {
self.exports.values().any(|ns| !ns.is_empty())
}
pub fn export_triples(&self) -> impl Iterator<Item = (&str, &str, &semver::Version)> {
self.exports.iter().flat_map(|(ns, ifaces)| {
ifaces
.iter()
.map(move |(name, def)| (ns.as_str(), name.as_str(), &def.version))
})
}
pub fn import_tuples(&self) -> impl Iterator<Item = (&str, &str, &semver::VersionReq, bool)> {
self.imports.iter().flat_map(|(ns, ifaces)| {
ifaces
.iter()
.map(move |(name, def)| (ns.as_str(), name.as_str(), &def.version, def.optional))
})
}
#[must_use]
pub fn effective_ipc_publish_patterns(&self) -> Vec<String> {
self.publishes.keys().cloned().collect()
}
#[must_use]
pub fn effective_ipc_subscribe_patterns(&self) -> Vec<String> {
self.subscribes.keys().cloned().collect()
}
#[must_use]
pub fn effective_interceptors(&self) -> Vec<InterceptorDef> {
self.subscribes
.iter()
.filter_map(|(topic, def)| {
def.handler.as_ref().map(|action| InterceptorDef {
event: topic.clone(),
action: action.clone(),
priority: def.priority.unwrap_or_else(default_interceptor_priority),
})
})
.collect()
}
}
fn deserialize_imports_map<'de, D>(de: D) -> Result<ImportsMap, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_dual_form_map::<D, ImportDef>(de, "imports")
}
fn deserialize_exports_map<'de, D>(de: D) -> Result<ExportsMap, D::Error>
where
D: serde::Deserializer<'de>,
{
deserialize_dual_form_map::<D, ExportDef>(de, "exports")
}
fn deserialize_dual_form_map<'de, D, T>(
de: D,
section: &'static str,
) -> Result<HashMap<String, HashMap<String, T>>, D::Error>
where
D: serde::Deserializer<'de>,
T: serde::de::DeserializeOwned + Clone,
{
use serde::de::Error;
let raw: HashMap<String, toml::Value> = HashMap::deserialize(de)?;
let mut out: HashMap<String, HashMap<String, T>> = HashMap::new();
for (key, value) in raw {
if let Some((ns, iface)) = key.split_once(':') {
if ns.is_empty() || iface.is_empty() {
return Err(D::Error::custom(format!(
"[{section}] key '{key}' has empty namespace or interface segment"
)));
}
let def: T = T::deserialize(value)
.map_err(|e| D::Error::custom(format!("[{section}] flat-form '{key}': {e}")))?;
out.entry(ns.to_string())
.or_default()
.insert(iface.to_string(), def);
} else {
let inner: HashMap<String, T> = HashMap::deserialize(value).map_err(|e| {
D::Error::custom(format!(
"[{section}.{key}]: expected table of interface declarations: {e}"
))
})?;
out.entry(key).or_default().extend(inner);
}
}
Ok(out)
}
pub type ImportsMap = HashMap<String, HashMap<String, ImportDef>>;
pub type ExportsMap = HashMap<String, HashMap<String, ExportDef>>;
#[derive(Debug, Clone, Serialize)]
pub struct ImportDef {
pub version: semver::VersionReq,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub optional: bool,
}
impl<'de> Deserialize<'de> for ImportDef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Short(String),
Full {
version: String,
#[serde(default)]
optional: bool,
},
}
let raw = Raw::deserialize(deserializer)?;
let (version_str, optional) = match raw {
Raw::Short(s) => (s, false),
Raw::Full { version, optional } => (version, optional),
};
let version = semver::VersionReq::parse(&version_str).map_err(|e| {
serde::de::Error::custom(format!("invalid semver requirement '{version_str}': {e}"))
})?;
Ok(Self { version, optional })
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ExportDef {
pub version: semver::Version,
}
impl<'de> Deserialize<'de> for ExportDef {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Short(String),
Full { version: String },
}
let raw = Raw::deserialize(deserializer)?;
let version_str = match raw {
Raw::Short(s) => s,
Raw::Full { version } => version,
};
let version = semver::Version::parse(&version_str).map_err(|e| {
serde::de::Error::custom(format!("invalid semver version '{version_str}': {e}"))
})?;
Ok(Self { version })
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PackageDef {
pub name: String,
pub version: String,
pub description: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
pub repository: Option<String>,
pub homepage: Option<String>,
pub documentation: Option<String>,
pub license: Option<String>,
#[serde(rename = "license-file")]
pub license_file: Option<PathBuf>,
pub readme: Option<PathBuf>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub categories: Vec<String>,
#[serde(rename = "astrid-version")]
pub astrid_version: Option<String>,
pub publish: Option<bool>,
pub include: Option<Vec<String>>,
pub exclude: Option<Vec<String>>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentDef {
#[serde(default)]
pub id: String,
#[serde(rename = "file", alias = "entrypoint")]
pub path: PathBuf,
pub hash: Option<String>,
#[serde(default)]
pub r#type: String,
#[serde(default)]
pub link: Vec<String>,
#[serde(default)]
pub capabilities: Option<CapabilitiesDef>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EnvDef {
#[serde(rename = "type")]
pub env_type: String,
pub request: Option<String>,
pub description: Option<String>,
pub default: Option<serde_json::Value>,
#[serde(default)]
pub enum_values: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placeholder: Option<String>,
#[serde(
default,
skip_serializing_if = "EnvScope::is_default",
skip_deserializing
)]
pub scope: EnvScope,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EnvScope {
#[default]
Agent,
Shared,
}
impl EnvScope {
#[must_use]
pub fn is_default(&self) -> bool {
matches!(self, Self::Agent)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextFileDef {
pub name: String,
pub file: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandDef {
pub name: String,
pub description: Option<String>,
pub file: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerDef {
pub id: String,
pub description: Option<String>,
#[serde(rename = "type")]
pub server_type: Option<String>,
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillDef {
pub name: String,
pub description: Option<String>,
pub file: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UplinkDef {
pub name: String,
pub platform: String,
pub profile: UplinkProfile,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InterceptorDef {
pub event: String,
pub action: String,
#[serde(default = "default_interceptor_priority")]
pub priority: u32,
}
const fn default_interceptor_priority() -> u32 {
100
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDef {
pub name: String,
pub description_for_llm: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_schema_wit: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub mutable: bool,
}