use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use astrid_capsule::ToolDescriptor;
use astrid_capsule::manifest::{CapsuleManifest, InterceptorDef};
use syn::visit::Visit;
use syn::{
Attribute, Expr, ImplItemFn, ItemFn, Lit, Meta, Token, TraitItemFn, punctuated::Punctuated,
};
use crate::theme::Theme;
const TOOL_EXECUTE_PREFIX: &str = "tool.v1.execute.";
const MANDATORY_PUBLISH: &[(&str, &str, &str)] = &[
(
"tool.v1.execute.*.result",
"@unicity-astrid/wit/types/tool-call-result",
"tool results can never return to the caller",
),
(
"tool.v1.response.describe.*",
"@unicity-astrid/wit/tool/describe-response",
"the tool describe fan-out cannot answer",
),
];
#[derive(Debug, Clone, PartialEq, Eq)]
struct Finding {
rule: &'static str,
message: String,
}
pub(crate) fn run(path: Option<&str>) -> Result<ExitCode> {
let dir = match path {
Some(p) => PathBuf::from(p),
None => std::env::current_dir().context("resolving the current directory")?,
};
let manifest_path = dir.join("Capsule.toml");
if !manifest_path.exists() {
anyhow::bail!(
"no Capsule.toml in {} — run `astrid capsule check` from a capsule project directory \
(or pass its path)",
dir.display()
);
}
let raw = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("reading {}", manifest_path.display()))?;
let manifest: CapsuleManifest =
toml::from_str(&raw).with_context(|| format!("parsing {}", manifest_path.display()))?;
let tool_names = scan_tool_annotations(&dir.join("src"))?;
let interceptors = manifest.effective_interceptors();
let publishes = manifest.effective_ipc_publish_patterns();
let findings = check_capsule(&tool_names, &interceptors, &publishes);
print_report(tool_names.len(), &findings);
Ok(if findings.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
})
}
fn check_capsule(
tool_names: &[String],
interceptors: &[InterceptorDef],
publish_patterns: &[String],
) -> Vec<Finding> {
let mut findings = Vec::new();
let descriptors: Vec<ToolDescriptor> = tool_names
.iter()
.map(|name| ToolDescriptor {
name: name.clone(),
description: String::new(),
input_schema: serde_json::Value::Null,
})
.collect();
for name in astrid_capsule::tools_missing_execute_route(&descriptors, interceptors) {
findings.push(Finding {
rule: "unrouted-tool",
message: format!(
"tool `{name}` is declared with #[astrid::tool] but has no \
`tool.v1.execute.{name}` subscription — it will advertise in tools/list but \
never execute. Add to Capsule.toml:\n [subscribe]\n \
\"tool.v1.execute.{name}\" = {{ wit = \"@unicity-astrid/wit/types/tool-call\", \
handler = \"tool_execute_{name}\" }}"
),
});
}
if !tool_names.is_empty() {
for (pattern, wit, consequence) in MANDATORY_PUBLISH {
if !publish_patterns.iter().any(|p| p == pattern) {
findings.push(Finding {
rule: "missing-publish",
message: format!(
"missing mandatory `[publish]` entry `{pattern}` — without it \
{consequence}. Add to Capsule.toml:\n [publish]\n \
\"{pattern}\" = {{ wit = \"{wit}\" }}"
),
});
}
}
}
for def in interceptors {
let Some(segment) = def.event.strip_prefix(TOOL_EXECUTE_PREFIX) else {
continue;
};
if segment.is_empty()
|| segment.contains('.')
|| segment.contains('*')
|| segment == "result"
{
continue;
}
if tool_names.iter().any(|n| n == segment) {
let expected = format!("tool_execute_{segment}");
if def.action != expected {
findings.push(Finding {
rule: "handler-mismatch",
message: format!(
"`[subscribe] \"tool.v1.execute.{segment}\"` handler is `{}` but must be \
`{expected}` — the macro-generated handler name. The call routes but the \
guest denies the unknown action, so the tool never runs.",
def.action
),
});
}
} else {
findings.push(Finding {
rule: "dangling-subscription",
message: format!(
"`[subscribe] \"tool.v1.execute.{segment}\"` has no matching \
#[astrid::tool(\"{segment}\")] — a typo or a removed tool. Fix the name or \
drop the subscription."
),
});
}
}
findings.sort_by(|a, b| a.rule.cmp(b.rule).then_with(|| a.message.cmp(&b.message)));
findings
}
fn print_report(tool_count: usize, findings: &[Finding]) {
if findings.is_empty() {
println!(
"{}",
Theme::success(&format!(
"capsule check passed — {tool_count} tool(s), all wired"
))
);
return;
}
for finding in findings {
println!(
"{}",
Theme::error(&format!("{}: {}", finding.rule, finding.message))
);
}
println!(
"{}",
Theme::error(&format!(
"{} problem(s) found — fix the above and re-run `astrid capsule check`",
findings.len()
))
);
}
fn scan_tool_annotations(src_dir: &Path) -> Result<Vec<String>> {
let mut names = Vec::new();
for path in rs_files(src_dir)? {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("reading Rust source {}", path.display()))?;
for name in tool_names_in_source(&content)
.with_context(|| format!("parsing Rust source {}", path.display()))?
{
if !names.contains(&name) {
names.push(name);
}
}
}
Ok(names)
}
fn rs_files(dir: &Path) -> Result<Vec<PathBuf>> {
if !dir.exists() {
return Ok(Vec::new());
}
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let entries = std::fs::read_dir(&d)
.with_context(|| format!("reading Rust source directory {}", d.display()))?;
for entry in entries {
let entry = entry.with_context(|| {
format!("reading an entry in Rust source directory {}", d.display())
})?;
let file_type = entry
.file_type()
.with_context(|| format!("reading file type for {}", entry.path().display()))?;
let p = entry.path();
if file_type.is_dir() {
stack.push(p);
} else if file_type.is_file() && p.extension().is_some_and(|ext| ext == "rs") {
out.push(p);
}
}
}
out.sort();
Ok(out)
}
fn tool_names_in_source(source: &str) -> syn::Result<Vec<String>> {
let file = syn::parse_file(source)?;
let mut visitor = ToolAttributeVisitor::default();
visitor.visit_file(&file);
Ok(visitor.names)
}
#[derive(Default)]
struct ToolAttributeVisitor {
names: Vec<String>,
}
impl<'ast> Visit<'ast> for ToolAttributeVisitor {
fn visit_item_fn(&mut self, function: &'ast ItemFn) {
self.record_tool(&function.attrs, &function.sig.ident.to_string());
syn::visit::visit_item_fn(self, function);
}
fn visit_impl_item_fn(&mut self, function: &'ast ImplItemFn) {
self.record_tool(&function.attrs, &function.sig.ident.to_string());
syn::visit::visit_impl_item_fn(self, function);
}
fn visit_trait_item_fn(&mut self, function: &'ast TraitItemFn) {
self.record_tool(&function.attrs, &function.sig.ident.to_string());
syn::visit::visit_trait_item_fn(self, function);
}
}
impl ToolAttributeVisitor {
fn record_tool(&mut self, attributes: &[Attribute], inferred_name: &str) {
for attribute in attributes {
let Some(name) = tool_attribute_name(attribute, inferred_name) else {
continue;
};
if !name.is_empty() {
self.names.push(name);
}
}
}
}
fn tool_attribute_name(attribute: &Attribute, inferred_name: &str) -> Option<String> {
let mut segments = attribute.path().segments.iter();
let is_tool = segments
.next()
.is_some_and(|segment| segment.ident == "astrid")
&& segments
.next()
.is_some_and(|segment| segment.ident == "tool")
&& segments.next().is_none();
if !is_tool {
return None;
}
match &attribute.meta {
Meta::Path(_) => Some(inferred_name.to_string()),
Meta::List(_) => {
let arguments = attribute
.parse_args_with(Punctuated::<Expr, Token![,]>::parse_terminated)
.ok()?;
if arguments.is_empty() {
return Some(inferred_name.to_string());
}
let Expr::Lit(literal) = arguments.first()? else {
return None;
};
let Lit::Str(name) = &literal.lit else {
return None;
};
Some(name.value())
},
Meta::NameValue(_) => None,
}
}
#[cfg(test)]
#[path = "check_tests.rs"]
mod tests;