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