use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, Result};
use astrid_capsule::ToolDescriptor;
use astrid_capsule::manifest::{CapsuleManifest, InterceptorDef};
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) -> Vec<String> {
let mut names = Vec::new();
for path in rs_files(src_dir) {
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
for line in content.lines() {
if let Some(name) = tool_name_in_line(line)
&& !names.contains(&name)
{
names.push(name);
}
}
}
names
}
fn rs_files(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
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
}
fn tool_name_in_line(line: &str) -> Option<String> {
const MARKER: &str = "astrid::tool";
let idx = line.find(MARKER)?;
if let Some(comment) = line.find("//")
&& comment < idx
{
return None;
}
let after = line.get(idx.saturating_add(MARKER.len())..)?.trim_start();
let inner = after.strip_prefix('(')?;
let q1 = inner.find('"')?;
let rest = inner.get(q1.saturating_add(1)..)?;
let q2 = rest.find('"')?;
let name = rest.get(..q2)?;
(!name.is_empty()).then(|| name.to_string())
}
#[cfg(test)]
#[path = "check_tests.rs"]
mod tests;