mod agents_txt;
mod ai_plugin;
mod mcp;
use crate::error::SsgError;
use crate::plugin::{Plugin, PluginContext};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub use agents_txt::{render_agents_txt, write_agents_txt};
pub use ai_plugin::{build_manifest, write_ai_plugin_json};
pub use mcp::{
build_registry, collect_mcp_resources, write_mcp_registry, McpResource,
};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentsConfig {
#[serde(default)]
pub agents_txt: bool,
#[serde(default)]
pub ai_plugin: bool,
#[serde(default)]
pub mcp: McpConfig,
#[serde(default)]
pub rules: HashMap<String, AgentRule>,
#[serde(default)]
pub default_rule: Option<AgentRule>,
}
impl AgentsConfig {
#[must_use]
pub const fn any_enabled(&self) -> bool {
self.agents_txt || self.ai_plugin || self.mcp.enabled
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_transport")]
pub transport: String,
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_protocol_version")]
pub protocol_version: String,
#[serde(default)]
pub auto_resources: bool,
#[serde(default)]
pub tools: Vec<McpToolDecl>,
#[serde(default)]
pub prompts: Vec<McpPromptDecl>,
}
fn default_transport() -> String {
"http".to_string()
}
fn default_protocol_version() -> String {
"2025-03-26".to_string()
}
impl Default for McpConfig {
fn default() -> Self {
Self {
enabled: false,
transport: default_transport(),
url: None,
protocol_version: default_protocol_version(),
auto_resources: false,
tools: Vec::new(),
prompts: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentRule {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub disallow: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolDecl {
pub name: String,
pub description: String,
#[serde(default, rename = "inputSchema")]
pub input_schema: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpPromptDecl {
pub name: String,
pub description: String,
#[serde(default)]
pub arguments: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct AgenticDiscoveryPlugin;
impl Plugin for AgenticDiscoveryPlugin {
fn name(&self) -> &'static str {
"agentic-discovery"
}
fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
if !ctx.site_dir.exists() {
return Ok(());
}
let Some(cfg) = ctx.config.as_ref() else {
return Ok(());
};
let Some(ref agents) = cfg.agents else {
return Ok(());
};
if !agents.any_enabled() {
return Ok(());
}
if agents.agents_txt {
write_agents_txt(ctx, agents)?;
}
if agents.ai_plugin {
write_ai_plugin_json(ctx, cfg)?;
}
if agents.mcp.enabled {
write_mcp_registry(ctx, cfg, agents)?;
}
Ok(())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::tempdir;
fn test_ctx(dir: &Path) -> PluginContext {
PluginContext::new(dir, dir, dir, dir)
}
#[test]
fn plugin_name_is_stable() {
assert_eq!(AgenticDiscoveryPlugin.name(), "agentic-discovery");
}
#[test]
fn no_config_is_no_op() {
let dir = tempdir().unwrap();
let ctx = test_ctx(dir.path());
AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
assert!(!dir.path().join("agents.txt").exists());
assert!(!dir.path().join(".well-known/ai-plugin.json").exists());
assert!(!dir.path().join(".well-known/mcp.json").exists());
}
#[test]
fn no_site_dir_is_no_op() {
let dir = tempdir().unwrap();
let missing = dir.path().join("nope");
let ctx = test_ctx(&missing);
AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
assert!(!missing.exists());
}
#[test]
fn any_enabled_false_by_default() {
let cfg = AgentsConfig::default();
assert!(!cfg.any_enabled());
}
#[test]
fn any_enabled_reflects_each_flag() {
let mut cfg = AgentsConfig::default();
cfg.agents_txt = true;
assert!(cfg.any_enabled());
let mut cfg = AgentsConfig::default();
cfg.ai_plugin = true;
assert!(cfg.any_enabled());
let mut cfg = AgentsConfig::default();
cfg.mcp.enabled = true;
assert!(cfg.any_enabled());
}
#[test]
fn mcp_defaults_are_sane() {
let mcp = McpConfig::default();
assert!(!mcp.enabled);
assert_eq!(mcp.transport, "http");
assert_eq!(mcp.protocol_version, "2025-03-26");
assert!(!mcp.auto_resources);
assert!(mcp.tools.is_empty());
assert!(mcp.prompts.is_empty());
assert!(mcp.url.is_none());
}
#[test]
fn parses_toml_with_all_three_emitters_enabled() {
let toml_str = r#"
agents_txt = true
ai_plugin = true
[mcp]
enabled = true
transport = "http"
auto_resources = true
[rules.gptbot]
allow = ["/blog/*"]
disallow = ["/"]
"#;
let cfg: AgentsConfig = toml::from_str(toml_str).unwrap();
assert!(cfg.agents_txt);
assert!(cfg.ai_plugin);
assert!(cfg.mcp.enabled);
assert!(cfg.mcp.auto_resources);
assert_eq!(cfg.mcp.transport, "http");
let rule = cfg.rules.get("gptbot").unwrap();
assert_eq!(rule.allow, vec!["/blog/*"]);
assert_eq!(rule.disallow, vec!["/"]);
}
#[test]
fn empty_toml_parses_with_defaults() {
let cfg: AgentsConfig = toml::from_str("").unwrap();
assert!(!cfg.any_enabled());
assert!(cfg.rules.is_empty());
assert!(cfg.default_rule.is_none());
}
fn ctx_with_config(dir: &Path, agents: AgentsConfig) -> PluginContext {
let mut cfg = crate::cmd::SsgConfig::default();
cfg.base_url = "https://example.test".to_string();
cfg.site_name = "Example".to_string();
cfg.site_title = "Example".to_string();
cfg.site_description = "A demo".to_string();
cfg.agents = Some(agents);
PluginContext::with_config(dir, dir, dir, dir, cfg)
}
#[test]
fn agents_txt_enabled_writes_file() {
let dir = tempdir().unwrap();
let agents = AgentsConfig {
agents_txt: true,
..AgentsConfig::default()
};
let ctx = ctx_with_config(dir.path(), agents);
AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
let body =
std::fs::read_to_string(dir.path().join("agents.txt")).unwrap();
assert!(body.contains("User-agent: *"));
assert!(!dir.path().join(".well-known/ai-plugin.json").exists());
assert!(!dir.path().join(".well-known/mcp.json").exists());
}
#[test]
fn ai_plugin_enabled_writes_file() {
let dir = tempdir().unwrap();
let agents = AgentsConfig {
ai_plugin: true,
..AgentsConfig::default()
};
let ctx = ctx_with_config(dir.path(), agents);
AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
let path = dir.path().join(".well-known/ai-plugin.json");
assert!(path.exists());
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("\"schema_version\""));
assert!(!dir.path().join("agents.txt").exists());
}
#[test]
fn mcp_enabled_writes_file() {
let dir = tempdir().unwrap();
let mut agents = AgentsConfig::default();
agents.mcp.enabled = true;
let ctx = ctx_with_config(dir.path(), agents);
AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
let path = dir.path().join(".well-known/mcp.json");
assert!(path.exists());
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("\"protocolVersion\""));
}
#[test]
fn all_three_emitters_enabled_writes_all_files() {
let dir = tempdir().unwrap();
let mut agents = AgentsConfig {
agents_txt: true,
ai_plugin: true,
..AgentsConfig::default()
};
agents.mcp.enabled = true;
let ctx = ctx_with_config(dir.path(), agents);
AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
assert!(dir.path().join("agents.txt").exists());
assert!(dir.path().join(".well-known/ai-plugin.json").exists());
assert!(dir.path().join(".well-known/mcp.json").exists());
}
#[test]
fn config_present_but_agents_none_is_no_op() {
let dir = tempdir().unwrap();
let cfg = crate::cmd::SsgConfig::default();
let ctx = PluginContext::with_config(
dir.path(),
dir.path(),
dir.path(),
dir.path(),
cfg,
);
AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
assert!(!dir.path().join("agents.txt").exists());
}
#[test]
fn all_flags_off_is_no_op_even_with_rules() {
let dir = tempdir().unwrap();
let mut agents = AgentsConfig::default();
let _ = agents.rules.insert(
"gptbot".to_string(),
AgentRule {
allow: vec!["/blog/*".to_string()],
disallow: vec![],
},
);
assert!(!agents.any_enabled());
let ctx = ctx_with_config(dir.path(), agents);
AgenticDiscoveryPlugin.after_compile(&ctx).unwrap();
assert!(!dir.path().join("agents.txt").exists());
}
#[test]
fn mcp_config_serde_round_trip() {
let mcp = McpConfig {
enabled: true,
transport: "http".to_string(),
url: Some("https://api.example/mcp".to_string()),
protocol_version: "2025-03-26".to_string(),
auto_resources: true,
tools: vec![McpToolDecl {
name: "search".to_string(),
description: "Search the site".to_string(),
input_schema: Some(serde_json::json!({"type":"object"})),
}],
prompts: vec![McpPromptDecl {
name: "summary".to_string(),
description: "Summarise".to_string(),
arguments: None,
}],
};
let json = serde_json::to_string(&mcp).unwrap();
let back: McpConfig = serde_json::from_str(&json).unwrap();
assert_eq!(back.transport, "http");
assert_eq!(back.tools.len(), 1);
assert_eq!(back.tools[0].name, "search");
assert_eq!(back.prompts[0].name, "summary");
}
#[test]
fn plugin_default_and_copy_traits() {
let a = AgenticDiscoveryPlugin;
let b: AgenticDiscoveryPlugin = a;
let _c = a;
assert_eq!(a.name(), b.name());
let default_plugin = <AgenticDiscoveryPlugin as Default>::default();
assert_eq!(default_plugin.name(), "agentic-discovery");
assert!(format!("{a:?}").contains("AgenticDiscoveryPlugin"));
}
}