1use crate::model::{SkillMetadata, SkillScope};
7use crate::render::{SKILL_OVERFLOW_SUFFIX, SKILL_OVERFLOW_SUFFIX_XML};
8use std::fmt::Write;
9
10pub use vtcode_config::core::skills::PromptFormat;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub enum SkillsRenderMode {
16 Full,
18 #[default]
20 Lean,
21}
22
23const SKILL_USAGE_RULES: &str = r#"
25**Usage Rules:**
26- **Discovery**: Skills listed above (name + description + file path)
27- **Trigger**: Use skill if user mentions `$SkillName` OR task matches description
28- **Progressive disclosure**:
29 1. Open SKILL.md to get full instructions
30 2. Load referenced files (scripts/, references/) only if needed
31 3. Prefer running existing scripts vs. retyping code
32- **Missing/blocked**: State issue briefly and continue with fallback approach
33- **Routing**: Treat `description` as the primary trigger signal
34"#;
35
36pub fn generate_skills_prompt(skills: &[SkillMetadata]) -> String {
38 generate_skills_prompt_with_mode(skills, SkillsRenderMode::Full)
39}
40
41pub fn generate_skills_prompt_with_mode(skills: &[SkillMetadata], mode: SkillsRenderMode) -> String {
43 if skills.is_empty() {
44 return String::new();
45 }
46
47 match mode {
48 SkillsRenderMode::Full => render_skills_full(skills),
49 SkillsRenderMode::Lean => render_skills_lean(skills),
50 }
51}
52
53fn render_skills_full(skills: &[SkillMetadata]) -> String {
59 render_skills_markdown(skills, true)
60}
61
62fn render_skills_lean(skills: &[SkillMetadata]) -> String {
66 render_skills_markdown(skills, false)
67}
68
69fn render_skills_markdown(skills: &[SkillMetadata], include_optional: bool) -> String {
76 let mut prompt = String::from("\n\n## Skills\n");
77 prompt.push_str(
78 "Available skills (name: description + directory + scope). Content on disk; open SKILL.md when triggered.\n\n",
79 );
80
81 let mut skill_list: Vec<_> = skills.iter().collect();
83 skill_list.sort_by_key(|skill| &skill.name);
84
85 let overflow = skill_list.len().saturating_sub(10);
87 if overflow > 0 {
88 skill_list.truncate(10);
89 }
90
91 for skill in skill_list {
92 let location = skill.path.display().to_string();
93 let scope = match skill.scope {
94 SkillScope::User => "user",
95 SkillScope::Repo => "repo",
96 SkillScope::System => "system",
97 SkillScope::Admin => "admin",
98 };
99
100 let mut line = format!("- {}: {} (file: {}, scope: {})", skill.name, skill.description, location, scope);
101 if include_optional && let Some(manifest) = &skill.manifest {
102 if let Some(compat) = &manifest.compatibility {
103 line.push_str(&format!(" [compat: {compat}]"));
104 }
105 if let Some(tools) = &manifest.allowed_tools {
106 line.push_str(&format!(" [tools: {tools}]"));
107 }
108 }
109
110 let _ = writeln!(prompt, "{line}");
111 }
112
113 if overflow > 0 {
114 let _ = write!(prompt, "\n(+{overflow} more skills available{SKILL_OVERFLOW_SUFFIX})");
115 }
116
117 prompt.push_str(SKILL_USAGE_RULES);
119
120 prompt
121}
122
123pub fn generate_skills_prompt_xml(skills: &[SkillMetadata]) -> String {
128 if skills.is_empty() {
129 return String::new();
130 }
131
132 let mut xml = String::from("\n<available_skills>\n");
133
134 let mut skill_list: Vec<_> = skills.iter().collect();
136 skill_list.sort_by_key(|skill| &skill.name);
137
138 let overflow = skill_list.len().saturating_sub(10);
140 if overflow > 0 {
141 skill_list.truncate(10);
142 }
143
144 for skill in skill_list {
145 xml.push_str(" <skill>\n");
146 let _ = writeln!(xml, " <name>{}</name>", xml_escape(&skill.name));
147 let _ = writeln!(xml, " <description>{}</description>", xml_escape(&skill.description));
148 let _ = writeln!(xml, " <location>{}</location>", xml_escape(&skill.path.display().to_string()));
149
150 if let Some(manifest) = &skill.manifest {
152 if let Some(ref compatibility) = manifest.compatibility {
153 let _ = writeln!(xml, " <compatibility>{}</compatibility>", xml_escape(compatibility));
154 }
155
156 if let Some(ref allowed_tools) = manifest.allowed_tools {
157 let _ = writeln!(xml, " <allowed-tools>{}</allowed-tools>", xml_escape(allowed_tools));
158 }
159 }
160
161 xml.push_str(" </skill>\n");
162 }
163
164 if overflow > 0 {
165 let _ = writeln!(xml, " <!-- +{overflow} more skills available{SKILL_OVERFLOW_SUFFIX_XML} -->");
166 }
167
168 xml.push_str("</available_skills>\n");
169 xml
170}
171
172fn xml_escape(s: &str) -> String {
174 s.replace('&', "&")
175 .replace('<', "<")
176 .replace('>', ">")
177 .replace('"', """)
178 .replace('\'', "'")
179}
180
181fn generate_skills_prompt_with_format(
183 skills: &[SkillMetadata],
184 render_mode: SkillsRenderMode,
185 format: PromptFormat,
186) -> String {
187 match format {
188 PromptFormat::Xml => generate_skills_prompt_xml(skills),
189 PromptFormat::Markdown => generate_skills_prompt_with_mode(skills, render_mode),
190 }
191}
192
193fn test_skills_prompt_generation() {
195 use crate::types::SkillManifest;
196 use std::path::PathBuf;
197
198 let mut skills = Vec::new();
199
200 let manifest = SkillManifest {
201 name: "pdf-analyzer".to_string(),
202 description: "Analyze PDF documents".to_string(),
203 ..Default::default()
204 };
205
206 let skill = SkillMetadata {
207 name: manifest.name.clone(),
208 description: manifest.description.clone(),
209 short_description: None,
210 path: PathBuf::from("/tmp/test"),
211 scope: SkillScope::User,
212 manifest: Some(manifest.into()),
213 };
214
215 skills.push(skill);
216
217 let prompt = generate_skills_prompt(&skills);
218 assert!(prompt.contains("pdf-analyzer"));
219 assert!(prompt.contains("Analyze PDF documents"));
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use std::path::PathBuf;
226
227 #[test]
228 fn test_empty_skills() {
229 let skills = Vec::new();
230 let prompt = generate_skills_prompt(&skills);
231 assert!(prompt.is_empty());
232 }
233
234 #[test]
235 fn test_skills_rendering() {
236 test_skills_prompt_generation();
237 }
238
239 #[test]
240 fn test_lean_rendering_mode() {
241 use crate::types::SkillManifest;
242 let mut skills = Vec::new();
243
244 let manifest = SkillManifest {
245 name: "test-skill".to_string(),
246 description: "Test skill description".to_string(),
247 ..Default::default()
248 };
249
250 let skill = SkillMetadata {
251 name: manifest.name.clone(),
252 description: manifest.description.clone(),
253 short_description: None,
254 path: PathBuf::from("/tmp/test-skill"),
255 scope: SkillScope::User,
256 manifest: Some(manifest.into()),
257 };
258
259 skills.push(skill);
260
261 let lean_prompt = generate_skills_prompt_with_mode(&skills, SkillsRenderMode::Lean);
262
263 assert!(lean_prompt.contains("test-skill"));
265 assert!(lean_prompt.contains("Test skill description"));
266 assert!(lean_prompt.contains("(file: /tmp/test-skill"));
267
268 assert!(lean_prompt.contains("Usage Rules"));
270 assert!(lean_prompt.contains("$SkillName"));
271 }
272
273 #[test]
274 fn test_full_vs_lean_token_savings() {
275 use crate::types::SkillManifest;
276 let mut skills = Vec::new();
277
278 for i in 0..5 {
279 let manifest = SkillManifest {
280 name: format!("skill-{i}"),
281 description: format!("Example skill number {i}"),
282 ..Default::default()
283 };
284
285 let skill = SkillMetadata {
286 name: manifest.name.clone(),
287 description: manifest.description.clone(),
288 short_description: None,
289 path: PathBuf::from(format!("/path/to/skill-{i}")),
290 scope: SkillScope::User,
291 manifest: Some(manifest.into()),
292 };
293
294 skills.push(skill);
295 }
296
297 let full_prompt = generate_skills_prompt_with_mode(&skills, SkillsRenderMode::Full);
298 let lean_prompt = generate_skills_prompt_with_mode(&skills, SkillsRenderMode::Lean);
299
300 assert_eq!(full_prompt, lean_prompt);
302 assert!(lean_prompt.contains("Usage Rules"));
303 assert!(full_prompt.contains("Available skills"));
304
305 let rich_manifest = SkillManifest {
307 name: "rich-skill".to_string(),
308 description: "Rich skill".to_string(),
309 compatibility: Some("Requires git".to_string()),
310 allowed_tools: Some("Read Bash".to_string()),
311 ..Default::default()
312 };
313 let rich = SkillMetadata {
314 name: rich_manifest.name.clone(),
315 description: rich_manifest.description.clone(),
316 short_description: None,
317 path: PathBuf::from("/tmp/rich-skill"),
318 scope: SkillScope::User,
319 manifest: Some(rich_manifest.into()),
320 };
321 let full_rich = generate_skills_prompt_with_mode(std::slice::from_ref(&rich), SkillsRenderMode::Full);
322 let lean_rich = generate_skills_prompt_with_mode(std::slice::from_ref(&rich), SkillsRenderMode::Lean);
323 assert!(full_rich.contains("[compat: Requires git]"));
324 assert!(full_rich.contains("[tools: Read Bash]"));
325 assert!(!lean_rich.contains("[compat:"));
326 assert!(!lean_rich.contains("[tools:"));
327 }
328
329 #[test]
330 fn test_xml_generation() {
331 use crate::types::SkillManifest;
332 let mut skills = Vec::new();
333 use hashbrown::HashMap as StdHashMap;
334
335 let mut metadata = StdHashMap::new();
336 metadata.insert("author".to_string(), serde_json::json!("Test Author"));
337
338 let manifest = SkillManifest {
339 name: "test-xml-skill".to_string(),
340 description: "Test XML generation".to_string(),
341 allowed_tools: Some("Read Write Bash".to_string()),
342 compatibility: Some("Designed for VT Code".to_string()),
343 metadata: Some(metadata),
344 ..Default::default()
345 };
346
347 let skill = SkillMetadata {
348 name: manifest.name.clone(),
349 description: manifest.description.clone(),
350 short_description: None,
351 path: PathBuf::from("/tmp/test-xml-skill"),
352 scope: SkillScope::User,
353 manifest: Some(manifest.into()),
354 };
355
356 skills.push(skill);
357
358 let xml_prompt = generate_skills_prompt_xml(&skills);
359
360 assert!(xml_prompt.contains("<available_skills>"));
362 assert!(xml_prompt.contains("</available_skills>"));
363 assert!(xml_prompt.contains("<skill>"));
364 assert!(xml_prompt.contains("</skill>"));
365
366 assert!(xml_prompt.contains("<name>test-xml-skill</name>"));
368 assert!(xml_prompt.contains("<description>Test XML generation</description>"));
369 assert!(xml_prompt.contains("<location>/tmp/test-xml-skill</location>"));
370
371 assert!(xml_prompt.contains("<compatibility>Designed for VT Code</compatibility>"));
373 assert!(xml_prompt.contains("<allowed-tools>Read Write Bash</allowed-tools>"));
374 }
375
376 #[test]
377 fn test_xml_escaping() {
378 use crate::types::SkillManifest;
379 let mut skills = Vec::new();
380
381 let manifest = SkillManifest {
382 name: "test-escape".to_string(),
383 description: "Test <special> & \"characters\"".to_string(),
384 ..Default::default()
385 };
386
387 let skill = SkillMetadata {
388 name: manifest.name.clone(),
389 description: manifest.description.clone(),
390 short_description: None,
391 path: PathBuf::from("/tmp/test"),
392 scope: SkillScope::User,
393 manifest: Some(manifest.into()),
394 };
395
396 skills.push(skill);
397
398 let xml_prompt = generate_skills_prompt_xml(&skills);
399
400 assert!(xml_prompt.contains("<special>"));
402 assert!(xml_prompt.contains("&"));
403 assert!(xml_prompt.contains("""));
404 }
405
406 #[test]
407 fn test_prompt_format_selection() {
408 use crate::types::SkillManifest;
409 let mut skills = Vec::new();
410
411 let manifest = SkillManifest {
412 name: "test-format".to_string(),
413 description: "Test format selection".to_string(),
414 ..Default::default()
415 };
416
417 let skill = SkillMetadata {
418 name: manifest.name.clone(),
419 description: manifest.description.clone(),
420 short_description: None,
421 path: PathBuf::from("/tmp/test"),
422 scope: SkillScope::User,
423 manifest: Some(manifest.into()),
424 };
425
426 skills.push(skill);
427
428 let xml_output = generate_skills_prompt_with_format(&skills, SkillsRenderMode::Lean, PromptFormat::Xml);
429 let markdown_output =
430 generate_skills_prompt_with_format(&skills, SkillsRenderMode::Lean, PromptFormat::Markdown);
431
432 assert!(xml_output.contains("<available_skills>"));
434 assert!(!markdown_output.contains("<available_skills>"));
435
436 assert!(markdown_output.contains("## Skills"));
438 assert!(!xml_output.contains("## Skills"));
439 }
440}