1use std::collections::HashMap;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use super::SubagentIndex;
9use crate::client::ModelType;
10use crate::common::{ContentSource, SourceType, is_markdown, parse_frontmatter};
11use crate::hooks::HookRule;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SubagentFrontmatter {
16 pub name: String,
17 pub description: String,
18 #[serde(default)]
19 pub tools: Option<String>,
20 #[serde(default)]
21 pub model: Option<String>,
22 #[serde(default)]
23 pub model_type: Option<String>,
24 #[serde(default)]
25 pub skills: Option<String>,
26 #[serde(default, rename = "source-type")]
27 pub source_type: Option<String>,
28 #[serde(default, alias = "disallowedTools")]
29 pub disallowed_tools: Option<String>,
30 #[serde(default, alias = "permissionMode")]
31 pub permission_mode: Option<String>,
32 #[serde(default)]
33 pub hooks: Option<HashMap<String, Vec<HookRule>>>,
34}
35
36fn split_csv(s: Option<String>) -> Vec<String> {
37 s.map(|v| {
38 let mut items = Vec::new();
39 let mut current = String::new();
40 let mut depth = 0u32;
41 for ch in v.chars() {
42 match ch {
43 '(' => {
44 depth += 1;
45 current.push(ch);
46 }
47 ')' => {
48 depth = depth.saturating_sub(1);
49 current.push(ch);
50 }
51 ',' if depth == 0 => {
52 let trimmed = current.trim().to_string();
53 if !trimmed.is_empty() {
54 items.push(trimmed);
55 }
56 current.clear();
57 }
58 _ => current.push(ch),
59 }
60 }
61 let trimmed = current.trim().to_string();
62 if !trimmed.is_empty() {
63 items.push(trimmed);
64 }
65 items
66 })
67 .unwrap_or_default()
68}
69
70#[derive(Debug, Clone, Copy, Default)]
71pub struct SubagentIndexLoader;
72
73impl SubagentIndexLoader {
74 pub fn new() -> Self {
75 Self
76 }
77
78 pub fn parse_index(&self, content: &str, path: &Path) -> crate::Result<SubagentIndex> {
79 let doc = parse_frontmatter::<SubagentFrontmatter>(content)?;
80 Ok(self.build_index(doc.frontmatter, path))
81 }
82
83 fn build_index(&self, fm: SubagentFrontmatter, path: &Path) -> SubagentIndex {
84 let source_type = SourceType::from_str_opt(fm.source_type.as_deref());
85
86 let tools = split_csv(fm.tools);
87 let skills = split_csv(fm.skills);
88 let disallowed_tools = split_csv(fm.disallowed_tools);
89
90 let mut index = SubagentIndex::new(fm.name, fm.description)
91 .source(ContentSource::file(path))
92 .source_type(source_type)
93 .tools(tools)
94 .skills(skills);
95
96 index.disallowed_tools = disallowed_tools;
97 index.permission_mode = fm.permission_mode;
98 index.hooks = fm.hooks;
99
100 if let Some(m) = fm.model {
101 index = index.model(m);
102 }
103
104 if let Some(mt) = fm.model_type {
105 match mt.to_lowercase().as_str() {
106 "small" | "haiku" => index = index.model_type(ModelType::Small),
107 "primary" | "sonnet" => index = index.model_type(ModelType::Primary),
108 "reasoning" | "opus" => index = index.model_type(ModelType::Reasoning),
109 _ => {}
110 }
111 }
112
113 index
114 }
115
116 pub async fn load_file(&self, path: &Path) -> crate::Result<SubagentIndex> {
118 crate::common::index_loader::load_file(path, |c, p| self.parse_index(c, p), "subagent")
119 .await
120 }
121
122 pub async fn scan_directory(&self, dir: &Path) -> crate::Result<Vec<SubagentIndex>> {
124 use crate::common::index_loader::{self, DirAction};
125
126 let loader = Self::new();
127 index_loader::scan_directory(
128 dir,
129 |p| Box::pin(async move { loader.load_file(p).await }),
130 is_markdown,
131 |_| DirAction::Recurse,
132 )
133 .await
134 }
135
136 pub fn create_inline(
138 name: impl Into<String>,
139 description: impl Into<String>,
140 prompt: impl Into<String>,
141 ) -> SubagentIndex {
142 SubagentIndex::new(name, description).source(ContentSource::in_memory(prompt))
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn test_parse_subagent_index() {
152 let content = r#"---
153name: code-reviewer
154description: Expert code reviewer for quality checks
155tools: Read, Grep, Glob
156model: haiku
157---
158
159You are a senior code reviewer focusing on:
160- Code quality and best practices
161- Security vulnerabilities
162"#;
163
164 let loader = SubagentIndexLoader::new();
165 let index = loader
166 .parse_index(content, Path::new("/test/reviewer.md"))
167 .unwrap();
168
169 assert_eq!(index.name, "code-reviewer");
170 assert_eq!(index.description, "Expert code reviewer for quality checks");
171 assert_eq!(index.allowed_tools, vec!["Read", "Grep", "Glob"]);
172 assert_eq!(index.model, Some("haiku".to_string()));
173 assert!(index.source.is_file());
174 }
175
176 #[test]
177 fn test_parse_subagent_with_skills() {
178 let content = r#"---
179name: full-agent
180description: Full featured agent
181tools: Read, Write, Bash(git:*)
182model: sonnet
183skills: security-check, linting
184---
185
186Full agent prompt.
187"#;
188
189 let loader = SubagentIndexLoader::new();
190 let index = loader
191 .parse_index(content, Path::new("/test/full.md"))
192 .unwrap();
193
194 assert_eq!(index.skills, vec!["security-check", "linting"]);
195 assert_eq!(index.model, Some("sonnet".to_string()));
196 }
197
198 #[test]
199 fn test_create_inline() {
200 let index = SubagentIndexLoader::create_inline(
201 "test-agent",
202 "Test description",
203 "You are a test agent.",
204 );
205
206 assert_eq!(index.name, "test-agent");
207 assert!(index.source.is_in_memory());
208 }
209
210 #[test]
211 fn test_parse_without_frontmatter() {
212 let content = "Just content without frontmatter";
213 let loader = SubagentIndexLoader::new();
214 assert!(loader.parse_index(content, Path::new("/test.md")).is_err());
215 }
216
217 #[test]
218 fn test_parse_disallowed_tools() {
219 let content = r#"---
220name: restricted-agent
221description: Agent with disallowed tools
222disallowedTools: Write, Edit
223---
224Restricted prompt"#;
225
226 let loader = SubagentIndexLoader::new();
227 let index = loader
228 .parse_index(content, Path::new("/test/restricted.md"))
229 .unwrap();
230
231 assert_eq!(index.disallowed_tools, vec!["Write", "Edit"]);
232 }
233
234 #[test]
235 fn test_parse_permission_mode() {
236 let content = r#"---
237name: auto-agent
238description: Agent with permission mode
239permissionMode: dontAsk
240---
241Auto prompt"#;
242
243 let loader = SubagentIndexLoader::new();
244 let index = loader
245 .parse_index(content, Path::new("/test/auto.md"))
246 .unwrap();
247
248 assert_eq!(index.permission_mode, Some("dontAsk".to_string()));
249 }
250
251 #[test]
252 fn test_split_csv_with_parens() {
253 let result = split_csv(Some("Read, Bash(git:*,docker:*), Write".to_string()));
254 assert_eq!(result, vec!["Read", "Bash(git:*,docker:*)", "Write"]);
255 }
256
257 #[test]
258 fn test_split_csv_simple() {
259 let result = split_csv(Some("Read, Grep, Glob".to_string()));
260 assert_eq!(result, vec!["Read", "Grep", "Glob"]);
261 }
262
263 #[test]
264 fn test_defaults_for_new_subagent_fields() {
265 let content = r#"---
266name: basic-agent
267description: Basic agent
268---
269Prompt"#;
270
271 let loader = SubagentIndexLoader::new();
272 let index = loader
273 .parse_index(content, Path::new("/test/basic.md"))
274 .unwrap();
275
276 assert!(index.disallowed_tools.is_empty());
277 assert!(index.permission_mode.is_none());
278 }
279}