use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
use tracing::{debug, info, warn};
use crate::error::{CapsuleError, CapsuleResult};
use crate::manifest::{CapsuleManifest, TopicDirection};
pub(crate) const MANIFEST_FILE_NAME: &str = "Capsule.toml";
pub fn discover_manifests(extra_paths: Option<&[PathBuf]>) -> Vec<(CapsuleManifest, PathBuf)> {
let mut manifests = Vec::new();
let local_capsules_dir = PathBuf::from(".astrid/capsules");
if local_capsules_dir.exists() {
info!(path = %local_capsules_dir.display(), "Discovering capsules from local directory");
match load_manifests_from_dir(&local_capsules_dir) {
Ok(found) => manifests.extend(found),
Err(e) => warn!(error = %e, "Failed to load capsules from local directory"),
}
}
if let Some(paths) = extra_paths {
for path in paths {
if path.exists() {
info!(path = %path.display(), "Discovering capsules from custom path");
match load_manifests_from_dir(path) {
Ok(found) => manifests.extend(found),
Err(e) => warn!(error = %e, "Failed to load capsules from custom path"),
}
}
}
}
info!(count = manifests.len(), "Discovered capsule manifests");
manifests
}
pub(crate) fn load_manifests_from_dir(
dir: &Path,
) -> CapsuleResult<Vec<(CapsuleManifest, PathBuf)>> {
let mut manifests = Vec::new();
let entries = std::fs::read_dir(dir).map_err(|e| CapsuleError::ManifestParseError {
path: dir.to_path_buf(),
message: e.to_string(),
})?;
for entry in entries {
let entry = entry.map_err(|e| CapsuleError::ManifestParseError {
path: dir.to_path_buf(),
message: e.to_string(),
})?;
let path = entry.path();
if path.is_dir() {
let manifest_path = path.join(MANIFEST_FILE_NAME);
if manifest_path.exists() {
match load_manifest(&manifest_path) {
Ok(manifest) => {
debug!(
path = %manifest_path.display(),
capsule_name = %manifest.package.name,
"Loaded capsule manifest"
);
manifests.push((manifest, path));
},
Err(e) => {
warn!(
path = %manifest_path.display(),
error = %e,
"Failed to load capsule manifest"
);
},
}
}
} else if path.is_file()
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == MANIFEST_FILE_NAME)
{
let capsule_dir = path.parent().unwrap_or(dir).to_path_buf();
match load_manifest(&path) {
Ok(manifest) => {
debug!(
path = %path.display(),
capsule_name = %manifest.package.name,
"Loaded capsule manifest"
);
manifests.push((manifest, capsule_dir));
},
Err(e) => {
warn!(path = %path.display(), error = %e, "Failed to load capsule manifest");
},
}
}
}
Ok(manifests)
}
pub fn load_manifest(path: &Path) -> CapsuleResult<CapsuleManifest> {
let content = std::fs::read_to_string(path).map_err(|e| CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: e.to_string(),
})?;
let manifest: CapsuleManifest =
toml::from_str(&content).map_err(|e| CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: e.to_string(),
})?;
if let Some(ref constraint) = manifest.package.astrid_version {
let runtime = semver::Version::parse(env!("CARGO_PKG_VERSION")).expect("valid semver");
let req = semver::VersionReq::parse(constraint).map_err(|e| {
CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!("invalid astrid-version '{constraint}' - {e}"),
}
})?;
if !req.matches(&runtime) {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"capsule requires astrid-version {constraint}, \
but this runtime is {runtime}"
),
});
}
}
if semver::Version::parse(&manifest.package.version).is_err() {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"invalid version '{}' in [package] - must be valid semver (MAJOR.MINOR.PATCH)",
manifest.package.version
),
});
}
let ipc_patterns = manifest
.capabilities
.ipc_publish
.iter()
.map(|p| ("ipc_publish pattern", p.as_str()));
let interceptor_patterns = manifest
.interceptors
.iter()
.map(|i| ("interceptor event pattern", i.event.as_str()));
for (kind, pattern) in ipc_patterns.chain(interceptor_patterns) {
if !crate::dispatcher::has_valid_segments(pattern) {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"{kind} '{pattern}' contains empty segments \
(consecutive dots, leading/trailing dots, or is empty)"
),
});
}
}
let dep_caps = manifest
.dependencies
.provides
.iter()
.map(|c| ("provides", c.as_str()))
.chain(
manifest
.dependencies
.requires
.iter()
.map(|c| ("requires", c.as_str())),
);
for (kind, cap) in dep_caps {
if cap.is_empty() {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!("[dependencies].{kind} contains an empty capability string"),
});
}
let Some((prefix, body)) = cap.split_once(':') else {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[dependencies].{kind} '{cap}' must have a type prefix \
(e.g. topic:, tool:, llm:, uplink:)"
),
});
};
const KNOWN_PREFIXES: &[&str] = &["topic", "tool", "llm", "uplink"];
if !KNOWN_PREFIXES.contains(&prefix) {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[dependencies].{kind} '{cap}' has unknown prefix '{prefix}:' \
(expected one of: topic:, tool:, llm:, uplink:)"
),
});
}
if !crate::dispatcher::has_valid_segments(body) {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[dependencies].{kind} '{cap}' body contains empty segments \
(consecutive dots, leading/trailing dots, or is empty)"
),
});
}
if kind == "provides" && body.contains('*') {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[dependencies].provides '{cap}' contains a wildcard - \
provides must be concrete capabilities, not patterns"
),
});
}
}
if manifest.capabilities.uplink && !manifest.dependencies.requires.is_empty() {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: "[dependencies].requires is not allowed on uplink capsules \
(uplinks load before non-uplinks and cannot depend on them)"
.into(),
});
}
{
let mut seen_topics: HashSet<(&str, TopicDirection)> = HashSet::new();
for topic in &manifest.topics {
if !crate::dispatcher::has_valid_segments(&topic.name) {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[[topic]] name '{}' contains empty segments \
(consecutive dots, leading/trailing dots, or is empty)",
topic.name
),
});
}
if !topic
.name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
{
if topic.name.contains('*') {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[[topic]] name '{}' must be a concrete topic name, not a pattern \
(wildcards are not allowed in topic declarations)",
topic.name
),
});
}
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[[topic]] name '{}' contains invalid characters \
(only alphanumeric, hyphens, underscores, and dots are allowed)",
topic.name
),
});
}
if let Some(ref schema_path) = topic.schema {
if schema_path.is_absolute() {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[[topic]] '{}' schema path must be relative, got absolute path '{}'",
topic.name,
schema_path.display()
),
});
}
if schema_path
.components()
.any(|c| matches!(c, Component::ParentDir))
{
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[[topic]] '{}' schema path must not contain '..' components: '{}'",
topic.name,
schema_path.display()
),
});
}
}
if !seen_topics.insert((&topic.name, topic.direction)) {
return Err(CapsuleError::ManifestParseError {
path: path.to_path_buf(),
message: format!(
"[[topic]] duplicate declaration: '{}' with direction '{}'",
topic.name, topic.direction
),
});
}
}
}
Ok(manifest)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn load_from_toml(toml: &str) -> CapsuleResult<crate::manifest::CapsuleManifest> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Capsule.toml");
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(toml.as_bytes()).unwrap();
load_manifest(&path)
}
const VALID_HEADER: &str = r#"
[package]
name = "test-capsule"
version = "0.1.0"
"#;
#[test]
fn load_manifest_accepts_valid_ipc_publish() {
let toml = format!(
"{VALID_HEADER}\n[capabilities]\nipc_publish = [\"registry.*\", \"llm.stream.anthropic\"]"
);
assert!(load_from_toml(&toml).is_ok());
}
#[test]
fn load_manifest_rejects_empty_segment_in_ipc_publish() {
for bad in &["a..b", ".a.b", "a.b.", "", ".", "a...b"] {
let toml = format!("{VALID_HEADER}\n[capabilities]\nipc_publish = [\"{bad}\"]");
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("empty segments"),
"expected 'empty segments' error for pattern '{bad}', got: {msg}"
);
}
}
#[test]
fn load_manifest_rejects_empty_segment_in_interceptor_event() {
for bad in &["a..b", ".event", "event.", "", ".", "a...b"] {
let toml =
format!("{VALID_HEADER}\n[[interceptor]]\nevent = \"{bad}\"\naction = \"handle\"");
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("empty segments"),
"expected 'empty segments' error for event '{bad}', got: {msg}"
);
}
}
#[test]
fn load_manifest_accepts_valid_interceptor_event() {
let toml = format!(
"{VALID_HEADER}\n[[interceptor]]\nevent = \"user.prompt\"\naction = \"handle\""
);
assert!(load_from_toml(&toml).is_ok());
}
#[test]
fn load_manifest_accepts_valid_semver() {
let toml = "[package]\nname = \"test\"\nversion = \"1.2.3\"\n";
assert!(load_from_toml(toml).is_ok());
}
#[test]
fn load_manifest_accepts_prerelease_semver() {
let toml = "[package]\nname = \"test\"\nversion = \"1.0.0-alpha.1\"\n";
assert!(load_from_toml(toml).is_ok());
}
#[test]
fn load_manifest_rejects_incomplete_semver() {
let toml = "[package]\nname = \"test\"\nversion = \"1.0\"\n";
let err = load_from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("invalid version"),
"expected 'invalid version' error, got: {err}"
);
}
#[test]
fn load_manifest_rejects_non_semver_version() {
let toml = "[package]\nname = \"test\"\nversion = \"latest\"\n";
let err = load_from_toml(toml).unwrap_err();
assert!(
err.to_string().contains("invalid version"),
"expected 'invalid version' error, got: {err}"
);
}
#[test]
fn load_manifest_parses_dependencies_provides_requires() {
let toml = format!(
"{VALID_HEADER}\n\
[dependencies]\n\
provides = [\"topic:identity.response.ready\"]\n\
requires = [\"topic:llm.stream.*\"]\n"
);
let m = load_from_toml(&toml).unwrap();
assert_eq!(
m.dependencies.provides,
vec!["topic:identity.response.ready"]
);
assert_eq!(m.dependencies.requires, vec!["topic:llm.stream.*"]);
}
#[test]
fn load_manifest_defaults_empty_dependencies() {
let m = load_from_toml(VALID_HEADER).unwrap();
assert!(m.dependencies.provides.is_empty());
assert!(m.dependencies.requires.is_empty());
assert!(m.dependencies.is_empty());
}
#[test]
fn load_manifest_parses_dependencies_provides_only() {
let toml = format!(
"{VALID_HEADER}\n\
[dependencies]\n\
provides = [\"topic:foo\", \"tool:bar\"]\n"
);
let m = load_from_toml(&toml).unwrap();
assert_eq!(m.dependencies.provides, vec!["topic:foo", "tool:bar"]);
assert!(m.dependencies.requires.is_empty());
}
#[test]
fn load_manifest_rejects_empty_capability_in_requires() {
let toml = format!("{VALID_HEADER}\n[dependencies]\nrequires = [\"\"]");
let err = load_from_toml(&toml).unwrap_err();
assert!(
err.to_string().contains("empty capability string"),
"expected 'empty capability string' error, got: {err}"
);
}
#[test]
fn load_manifest_rejects_missing_prefix_in_provides() {
let toml = format!("{VALID_HEADER}\n[dependencies]\nprovides = [\"no_prefix\"]");
let err = load_from_toml(&toml).unwrap_err();
assert!(
err.to_string().contains("must have a type prefix"),
"expected 'must have a type prefix' error, got: {err}"
);
}
#[test]
fn load_manifest_rejects_unknown_prefix_in_requires() {
let toml = format!("{VALID_HEADER}\n[dependencies]\nrequires = [\"service:foo\"]");
let err = load_from_toml(&toml).unwrap_err();
assert!(
err.to_string().contains("unknown prefix"),
"expected 'unknown prefix' error, got: {err}"
);
}
#[test]
fn load_manifest_rejects_empty_segments_in_dependency_body() {
let toml = format!("{VALID_HEADER}\n[dependencies]\nprovides = [\"topic:a..b\"]");
let err = load_from_toml(&toml).unwrap_err();
assert!(
err.to_string().contains("empty segments"),
"expected 'empty segments' error, got: {err}"
);
}
#[test]
fn load_manifest_rejects_wildcard_in_provides() {
let toml = format!("{VALID_HEADER}\n[dependencies]\nprovides = [\"topic:llm.stream.*\"]");
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("wildcard"),
"expected 'wildcard' error for provides with *, got: {msg}"
);
}
#[test]
fn load_manifest_allows_wildcard_in_requires() {
let toml = format!("{VALID_HEADER}\n[dependencies]\nrequires = [\"topic:llm.stream.*\"]");
assert!(
load_from_toml(&toml).is_ok(),
"wildcards should be allowed in requires"
);
}
#[test]
fn load_manifest_rejects_uplink_with_requires() {
let toml = format!(
"{VALID_HEADER}\n[capabilities]\nuplink = true\n\n[dependencies]\nrequires = [\"topic:foo\"]"
);
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("not allowed on uplink"),
"expected uplink+requires rejection, got: {msg}"
);
}
#[test]
fn load_manifest_allows_uplink_without_requires() {
let toml = format!("{VALID_HEADER}\n[capabilities]\nuplink = true");
assert!(
load_from_toml(&toml).is_ok(),
"uplink without requires should be valid"
);
}
#[test]
fn load_manifest_accepts_satisfied_astrid_version() {
let toml = "[package]\nname = \"test\"\nversion = \"0.1.0\"\nastrid-version = \">=0.1.0\"";
assert!(load_from_toml(toml).is_ok());
}
#[test]
fn load_manifest_rejects_unsatisfied_astrid_version() {
let toml = "[package]\nname = \"test\"\nversion = \"0.1.0\"\nastrid-version = \">=99.0.0\"";
let err = load_from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("astrid-version") && msg.contains("99.0.0"),
"expected astrid-version rejection, got: {msg}"
);
}
#[test]
fn load_manifest_rejects_invalid_astrid_version() {
let toml =
"[package]\nname = \"test\"\nversion = \"0.1.0\"\nastrid-version = \"not-semver\"";
let err = load_from_toml(toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("invalid astrid-version"),
"expected parse error, got: {msg}"
);
}
#[test]
fn load_manifest_accepts_missing_astrid_version() {
assert!(load_from_toml(VALID_HEADER).is_ok());
}
#[test]
fn topic_parses_valid_publish_and_subscribe() {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"llm.v1.response.chunk\"\n\
direction = \"publish\"\n\
description = \"Streaming LLM response chunk\"\n\
\n\
[[topic]]\n\
name = \"llm.v1.request.generate\"\n\
direction = \"subscribe\"\n"
);
let manifest = load_from_toml(&toml).expect("valid topics");
assert_eq!(manifest.topics.len(), 2);
assert_eq!(manifest.topics[0].direction, TopicDirection::Publish);
assert_eq!(manifest.topics[1].direction, TopicDirection::Subscribe);
}
#[test]
fn topic_without_optional_fields() {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"events.v1.notify\"\n\
direction = \"publish\"\n"
);
let manifest = load_from_toml(&toml).expect("valid topic without optionals");
assert_eq!(manifest.topics.len(), 1);
assert!(manifest.topics[0].description.is_none());
assert!(manifest.topics[0].schema.is_none());
}
#[test]
fn topic_rejects_invalid_direction() {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"foo.bar\"\n\
direction = \"bidirectional\"\n"
);
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("unknown variant"),
"expected serde enum error, got: {msg}"
);
}
#[test]
fn topic_rejects_empty_segment_name() {
for bad in &["a..b", ".a.b", "a.b.", "", "."] {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"{bad}\"\n\
direction = \"publish\"\n"
);
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("empty segments"),
"expected 'empty segments' error for name '{bad}', got: {msg}"
);
}
}
#[test]
fn topic_rejects_absolute_schema_path() {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"foo.bar\"\n\
direction = \"publish\"\n\
schema = \"/etc/passwd\"\n"
);
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("must be relative"),
"expected relative path error, got: {msg}"
);
}
#[test]
fn topic_rejects_parent_dir_in_schema_path() {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"foo.bar\"\n\
direction = \"publish\"\n\
schema = \"../escape.json\"\n"
);
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("'..'"),
"expected parent dir error, got: {msg}"
);
}
#[test]
fn topic_rejects_wildcard_segment_name() {
for bad in &["llm.v1.*", "*.response", "a.*.b"] {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"{bad}\"\n\
direction = \"publish\"\n"
);
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("wildcard"),
"expected wildcard error for name '{bad}', got: {msg}"
);
}
}
#[test]
fn topic_rejects_invalid_characters() {
for bad in &["llm response", "foo@bar", "a/b/c", "topic!bang"] {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"{bad}\"\n\
direction = \"publish\"\n"
);
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("invalid characters"),
"expected invalid characters error for name '{bad}', got: {msg}"
);
}
}
#[test]
fn topic_rejects_duplicate_name_direction_pair() {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"foo.bar\"\n\
direction = \"publish\"\n\
\n\
[[topic]]\n\
name = \"foo.bar\"\n\
direction = \"publish\"\n"
);
let err = load_from_toml(&toml).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("duplicate"),
"expected duplicate error, got: {msg}"
);
}
#[test]
fn topic_allows_same_name_different_direction() {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"echo.v1\"\n\
direction = \"publish\"\n\
\n\
[[topic]]\n\
name = \"echo.v1\"\n\
direction = \"subscribe\"\n"
);
let manifest = load_from_toml(&toml).expect("same name different direction is valid");
assert_eq!(manifest.topics.len(), 2);
}
#[test]
fn topic_backwards_compat_no_topics_section() {
let manifest = load_from_toml(VALID_HEADER).expect("no topics section is fine");
assert!(manifest.topics.is_empty());
}
#[test]
fn topic_with_schema_path() {
let toml = format!(
"{VALID_HEADER}\n\
[[topic]]\n\
name = \"llm.v1.chunk\"\n\
direction = \"publish\"\n\
schema = \"schemas/chunk.json\"\n"
);
let manifest = load_from_toml(&toml).expect("schema path is valid");
assert_eq!(
manifest.topics[0].schema.as_deref(),
Some(std::path::Path::new("schemas/chunk.json"))
);
}
}