1use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5use std::sync::{OnceLock, RwLock};
6use std::time::{Duration, SystemTime};
7
8use serde::Deserialize;
9use tokio::fs;
10use tracing::warn;
11
12const PROMPTS_DIR: &str = ".vtcode/prompts";
13const TEMPLATES_DIR: &str = "templates";
14const SYSTEM_PROMPT_FILENAME: &str = "system.md";
15const APPEND_SYSTEM_PROMPT_FILENAME: &str = "append-system.md";
16const PROMPT_RESOURCE_CACHE_TTL: Duration = Duration::from_secs(5 * 60);
17const PROMPT_RESOURCE_CACHE_MAX_ENTRIES: usize = 32;
18
19static SYSTEM_PROMPT_LAYERS_CACHE: OnceLock<
20 RwLock<HashMap<PromptResourceCacheKey, CachedSystemPromptLayers>>,
21> = OnceLock::new();
22static PROMPT_TEMPLATES_CACHE: OnceLock<
23 RwLock<HashMap<PromptResourceCacheKey, CachedPromptTemplates>>,
24> = OnceLock::new();
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct PromptTemplate {
28 pub name: String,
29 pub description: String,
30 pub body: String,
31 pub path: PathBuf,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct SystemPromptLayers {
36 pub override_body: Option<String>,
37 pub append_bodies: Vec<String>,
38}
39
40#[derive(Debug, Clone, Copy)]
41enum PromptResourceScope {
42 User,
43 Workspace,
44}
45
46#[derive(Debug, Clone)]
47struct PromptResourceOptions<'a> {
48 workspace_root: &'a Path,
49 home_dir: Option<PathBuf>,
50}
51
52#[derive(Debug, Clone, Default, Deserialize)]
53struct PromptTemplateFrontmatter {
54 description: Option<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Hash)]
58struct PromptResourceCacheKey {
59 workspace_root: PathBuf,
60 home_dir: Option<PathBuf>,
61}
62
63impl PromptResourceCacheKey {
64 fn new(options: &PromptResourceOptions<'_>) -> Self {
65 Self {
66 workspace_root: normalize_cache_path(options.workspace_root),
67 home_dir: options.home_dir.as_deref().map(normalize_cache_path),
68 }
69 }
70}
71
72#[derive(Clone)]
73struct CachedPromptTemplates {
74 templates: Vec<PromptTemplate>,
75 timestamp: SystemTime,
76}
77
78impl CachedPromptTemplates {
79 fn is_expired(&self) -> bool {
80 self.timestamp
81 .elapsed()
82 .unwrap_or(PROMPT_RESOURCE_CACHE_TTL)
83 > PROMPT_RESOURCE_CACHE_TTL
84 }
85}
86
87#[derive(Clone)]
88struct CachedSystemPromptLayers {
89 layers: SystemPromptLayers,
90 timestamp: SystemTime,
91}
92
93impl CachedSystemPromptLayers {
94 fn is_expired(&self) -> bool {
95 self.timestamp
96 .elapsed()
97 .unwrap_or(PROMPT_RESOURCE_CACHE_TTL)
98 > PROMPT_RESOURCE_CACHE_TTL
99 }
100}
101
102fn normalize_cache_path(path: &Path) -> PathBuf {
103 dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
104}
105
106fn system_prompt_layers_cache()
107-> &'static RwLock<HashMap<PromptResourceCacheKey, CachedSystemPromptLayers>> {
108 SYSTEM_PROMPT_LAYERS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
109}
110
111fn prompt_templates_cache()
112-> &'static RwLock<HashMap<PromptResourceCacheKey, CachedPromptTemplates>> {
113 PROMPT_TEMPLATES_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
114}
115
116fn get_cached_system_prompt_layers(key: &PromptResourceCacheKey) -> Option<SystemPromptLayers> {
117 match system_prompt_layers_cache().read() {
118 Ok(cache) => cache
119 .get(key)
120 .filter(|cached| !cached.is_expired())
121 .map(|cached| cached.layers.clone()),
122 Err(_) => {
123 warn!("system prompt layers cache lock poisoned while reading cache");
124 None
125 }
126 }
127}
128
129fn cache_system_prompt_layers(key: PromptResourceCacheKey, layers: &SystemPromptLayers) {
130 match system_prompt_layers_cache().write() {
131 Ok(mut cache) => {
132 if cache.len() >= PROMPT_RESOURCE_CACHE_MAX_ENTRIES && !cache.contains_key(&key) {
133 let expired: Vec<_> = cache
134 .iter()
135 .filter(|(_, value)| value.is_expired())
136 .map(|(cache_key, _)| cache_key.clone())
137 .collect();
138 for cache_key in expired {
139 cache.remove(&cache_key);
140 }
141
142 if cache.len() >= PROMPT_RESOURCE_CACHE_MAX_ENTRIES {
143 let oldest_key = cache
144 .iter()
145 .min_by_key(|(_, value)| value.timestamp)
146 .map(|(cache_key, _)| cache_key.clone());
147 if let Some(oldest_key) = oldest_key {
148 cache.remove(&oldest_key);
149 }
150 }
151 }
152
153 cache.insert(
154 key,
155 CachedSystemPromptLayers {
156 layers: layers.clone(),
157 timestamp: SystemTime::now(),
158 },
159 );
160 }
161 Err(_) => warn!("system prompt layers cache lock poisoned while writing cache"),
162 }
163}
164
165fn get_cached_prompt_templates(key: &PromptResourceCacheKey) -> Option<Vec<PromptTemplate>> {
166 match prompt_templates_cache().read() {
167 Ok(cache) => cache
168 .get(key)
169 .filter(|cached| !cached.is_expired())
170 .map(|cached| cached.templates.clone()),
171 Err(_) => {
172 warn!("prompt templates cache lock poisoned while reading cache");
173 None
174 }
175 }
176}
177
178fn cache_prompt_templates(key: PromptResourceCacheKey, templates: &[PromptTemplate]) {
179 match prompt_templates_cache().write() {
180 Ok(mut cache) => {
181 if cache.len() >= PROMPT_RESOURCE_CACHE_MAX_ENTRIES && !cache.contains_key(&key) {
182 let expired: Vec<_> = cache
183 .iter()
184 .filter(|(_, value)| value.is_expired())
185 .map(|(cache_key, _)| cache_key.clone())
186 .collect();
187 for cache_key in expired {
188 cache.remove(&cache_key);
189 }
190
191 if cache.len() >= PROMPT_RESOURCE_CACHE_MAX_ENTRIES {
192 let oldest_key = cache
193 .iter()
194 .min_by_key(|(_, value)| value.timestamp)
195 .map(|(cache_key, _)| cache_key.clone());
196 if let Some(oldest_key) = oldest_key {
197 cache.remove(&oldest_key);
198 }
199 }
200 }
201
202 cache.insert(
203 key,
204 CachedPromptTemplates {
205 templates: templates.to_vec(),
206 timestamp: SystemTime::now(),
207 },
208 );
209 }
210 Err(_) => warn!("prompt templates cache lock poisoned while writing cache"),
211 }
212}
213
214#[cfg(test)]
215fn clear_prompt_resource_caches() {
216 if let Ok(mut cache) = system_prompt_layers_cache().write() {
217 cache.clear();
218 }
219 if let Ok(mut cache) = prompt_templates_cache().write() {
220 cache.clear();
221 }
222}
223
224pub async fn resolve_system_prompt_layers(workspace_root: &Path) -> SystemPromptLayers {
225 resolve_system_prompt_layers_with_options(PromptResourceOptions::new(workspace_root)).await
226}
227
228pub async fn discover_prompt_templates(workspace_root: &Path) -> Vec<PromptTemplate> {
229 discover_prompt_templates_with_options(PromptResourceOptions::new(workspace_root)).await
230}
231
232pub async fn find_prompt_template(workspace_root: &Path, name: &str) -> Option<PromptTemplate> {
233 let normalized = name.trim();
234 if normalized.is_empty() {
235 return None;
236 }
237
238 find_prompt_template_with_options(PromptResourceOptions::new(workspace_root), normalized).await
239}
240
241pub fn apply_system_prompt_layers(base_prompt: &str, layers: &SystemPromptLayers) -> String {
242 let mut prompt = String::new();
243
244 if let Some(override_body) = layers.override_body.as_deref().map(str::trim)
245 && !override_body.is_empty()
246 {
247 prompt.push_str(override_body);
248 } else {
249 prompt.push_str(base_prompt);
250 }
251
252 for append_body in &layers.append_bodies {
253 let trimmed = append_body.trim();
254 if trimmed.is_empty() {
255 continue;
256 }
257 if !prompt.is_empty() {
258 prompt.push_str("\n\n");
259 }
260 prompt.push_str(trimmed);
261 }
262
263 prompt
264}
265
266pub fn expand_prompt_template(body: &str, args: &[String]) -> String {
267 let joined_args = args.join(" ");
268 let mut expanded = String::with_capacity(body.len() + joined_args.len());
269 let chars: Vec<char> = body.chars().collect();
270 let mut index = 0;
271
272 while index < chars.len() {
273 if chars[index] != '$' {
274 expanded.push(chars[index]);
275 index += 1;
276 continue;
277 }
278
279 if index + 1 >= chars.len() {
280 expanded.push('$');
281 index += 1;
282 continue;
283 }
284
285 match chars[index + 1] {
286 '@' => {
287 expanded.push_str(&joined_args);
288 index += 2;
289 }
290 'A' => {
291 const ARGUMENTS_TOKEN: &str = "ARGUMENTS";
292 let remaining: String = chars[index + 1..].iter().collect();
293 if remaining.starts_with(ARGUMENTS_TOKEN) {
294 expanded.push_str(&joined_args);
295 index += ARGUMENTS_TOKEN.chars().count() + 1;
296 } else {
297 expanded.push('$');
298 index += 1;
299 }
300 }
301 digit if digit.is_ascii_digit() => {
302 let mut cursor = index + 1;
303 while cursor < chars.len() && chars[cursor].is_ascii_digit() {
304 cursor += 1;
305 }
306 let ordinal: String = chars[index + 1..cursor].iter().collect();
307 let replacement = ordinal
308 .parse::<usize>()
309 .ok()
310 .and_then(|value| value.checked_sub(1))
311 .and_then(|position| args.get(position))
312 .map(String::as_str)
313 .unwrap_or("");
314 expanded.push_str(replacement);
315 index = cursor;
316 }
317 _ => {
318 expanded.push('$');
319 index += 1;
320 }
321 }
322 }
323
324 expanded
325}
326
327impl<'a> PromptResourceOptions<'a> {
328 fn new(workspace_root: &'a Path) -> Self {
329 #[cfg(test)]
330 let home_dir = None;
331
332 #[cfg(not(test))]
333 let home_dir = dirs::home_dir();
334
335 Self {
336 workspace_root,
337 home_dir,
338 }
339 }
340}
341
342async fn resolve_system_prompt_layers_with_options(
343 options: PromptResourceOptions<'_>,
344) -> SystemPromptLayers {
345 let cache_key = PromptResourceCacheKey::new(&options);
346 if let Some(cached) = get_cached_system_prompt_layers(&cache_key) {
347 return cached;
348 }
349
350 let layers = resolve_system_prompt_layers_uncached(&options).await;
351 cache_system_prompt_layers(cache_key, &layers);
352 layers
353}
354
355async fn resolve_system_prompt_layers_uncached(
356 options: &PromptResourceOptions<'_>,
357) -> SystemPromptLayers {
358 let mut layers = SystemPromptLayers::default();
359
360 let user_system_path = options
361 .home_dir
362 .as_ref()
363 .map(|home| home.join(PROMPTS_DIR).join(SYSTEM_PROMPT_FILENAME));
364 let workspace_system_path = options
365 .workspace_root
366 .join(PROMPTS_DIR)
367 .join(SYSTEM_PROMPT_FILENAME);
368
369 if let Some(path) = user_system_path.as_ref() {
370 layers.override_body = read_optional_markdown(path).await;
371 }
372
373 if let Some(workspace_override) = read_optional_markdown(&workspace_system_path).await {
374 layers.override_body = Some(workspace_override);
375 }
376
377 if let Some(path) = options
378 .home_dir
379 .as_ref()
380 .map(|home| home.join(PROMPTS_DIR).join(APPEND_SYSTEM_PROMPT_FILENAME))
381 && let Some(contents) = read_optional_markdown(&path).await
382 {
383 layers.append_bodies.push(contents);
384 }
385
386 let workspace_append = options
387 .workspace_root
388 .join(PROMPTS_DIR)
389 .join(APPEND_SYSTEM_PROMPT_FILENAME);
390 if let Some(contents) = read_optional_markdown(&workspace_append).await {
391 layers.append_bodies.push(contents);
392 }
393
394 layers
395}
396
397async fn discover_prompt_templates_with_options(
398 options: PromptResourceOptions<'_>,
399) -> Vec<PromptTemplate> {
400 let cache_key = PromptResourceCacheKey::new(&options);
401 if let Some(cached) = get_cached_prompt_templates(&cache_key) {
402 return cached;
403 }
404
405 let templates = discover_prompt_templates_uncached(&options).await;
406 cache_prompt_templates(cache_key, &templates);
407 templates
408}
409
410async fn discover_prompt_templates_uncached(
411 options: &PromptResourceOptions<'_>,
412) -> Vec<PromptTemplate> {
413 let mut discovered = BTreeMap::new();
414
415 if let Some(home) = options.home_dir.as_deref() {
416 let user_templates = home.join(PROMPTS_DIR).join(TEMPLATES_DIR);
417 merge_prompt_templates(&mut discovered, &user_templates, PromptResourceScope::User).await;
418 }
419
420 let workspace_templates = options.workspace_root.join(PROMPTS_DIR).join(TEMPLATES_DIR);
421 merge_prompt_templates(
422 &mut discovered,
423 &workspace_templates,
424 PromptResourceScope::Workspace,
425 )
426 .await;
427
428 discovered.into_values().collect()
429}
430
431async fn find_prompt_template_with_options(
432 options: PromptResourceOptions<'_>,
433 name: &str,
434) -> Option<PromptTemplate> {
435 if !is_safe_template_name(name) {
436 return None;
437 }
438
439 discover_prompt_templates_with_options(options)
440 .await
441 .into_iter()
442 .find(|template| template.name == name)
443}
444
445async fn merge_prompt_templates(
446 discovered: &mut BTreeMap<String, PromptTemplate>,
447 directory: &Path,
448 scope: PromptResourceScope,
449) {
450 let Ok(mut entries) = fs::read_dir(directory).await else {
451 return;
452 };
453
454 let mut markdown_files = Vec::new();
455 loop {
456 match entries.next_entry().await {
457 Ok(Some(entry)) => {
458 let path = entry.path();
459 if path.extension().and_then(|ext| ext.to_str()) == Some("md") {
460 markdown_files.push(path);
461 }
462 }
463 Ok(None) => break,
464 Err(err) => {
465 warn!(
466 "failed to read prompt templates directory {}: {}",
467 directory.display(),
468 err
469 );
470 break;
471 }
472 }
473 }
474
475 markdown_files.sort();
476
477 for path in markdown_files {
478 let Some(name) = path
479 .file_stem()
480 .and_then(|stem| stem.to_str())
481 .map(str::trim)
482 .filter(|stem| !stem.is_empty())
483 .map(str::to_string)
484 else {
485 continue;
486 };
487
488 match load_prompt_template(&path, name.clone()).await {
489 Some(template) => {
490 if matches!(scope, PromptResourceScope::Workspace) {
491 discovered.insert(name, template);
492 } else {
493 discovered.entry(name).or_insert(template);
494 }
495 }
496 None => continue,
497 }
498 }
499}
500
501async fn load_prompt_template(path: &Path, name: String) -> Option<PromptTemplate> {
502 let raw = read_optional_markdown(path).await?;
503 let normalized = normalize_newlines(&raw);
504 let (frontmatter, body) = parse_frontmatter(&normalized);
505 let description = frontmatter
506 .description
507 .filter(|value| !value.trim().is_empty())
508 .unwrap_or_else(|| derive_template_description(&body, &name));
509
510 Some(PromptTemplate {
511 name,
512 description,
513 body: body.trim().to_string(),
514 path: path.to_path_buf(),
515 })
516}
517
518async fn read_optional_markdown(path: &Path) -> Option<String> {
519 match fs::read_to_string(path).await {
520 Ok(contents) => Some(contents),
521 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
522 Err(err) => {
523 warn!("failed to read prompt resource {}: {}", path.display(), err);
524 None
525 }
526 }
527}
528
529fn parse_frontmatter(content: &str) -> (PromptTemplateFrontmatter, String) {
530 if !content.starts_with("---\n") {
531 return (PromptTemplateFrontmatter::default(), content.to_string());
532 }
533
534 let Some(frontmatter_end) = content[4..].find("\n---\n").map(|idx| idx + 4) else {
535 return (PromptTemplateFrontmatter::default(), content.to_string());
536 };
537
538 let yaml = &content[4..frontmatter_end];
539 let body_start = frontmatter_end + 5;
540 let body = if body_start < content.len() {
541 content[body_start..].to_string()
542 } else {
543 String::new()
544 };
545
546 let metadata = match serde_saphyr::from_str::<PromptTemplateFrontmatter>(yaml.trim()) {
547 Ok(value) => value,
548 Err(err) => {
549 warn!("failed to parse prompt template frontmatter: {}", err);
550 PromptTemplateFrontmatter::default()
551 }
552 };
553
554 (metadata, body)
555}
556
557fn derive_template_description(body: &str, name: &str) -> String {
558 for line in body.lines().map(str::trim) {
559 if line.is_empty() {
560 continue;
561 }
562 if let Some(heading) = line.strip_prefix('#') {
563 let trimmed = heading.trim_start_matches('#').trim();
564 if !trimmed.is_empty() {
565 return trimmed.to_string();
566 }
567 }
568 return line.to_string();
569 }
570
571 format!("Prompt template `{}`", name)
572}
573
574fn normalize_newlines(content: &str) -> String {
575 content.replace("\r\n", "\n")
576}
577
578fn is_safe_template_name(name: &str) -> bool {
579 !name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains("..")
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use serial_test::serial;
586
587 async fn discover_with_roots(workspace: &Path, home: Option<&Path>) -> Vec<PromptTemplate> {
588 discover_prompt_templates_with_options(PromptResourceOptions {
589 workspace_root: workspace,
590 home_dir: home.map(Path::to_path_buf),
591 })
592 .await
593 }
594
595 async fn layers_with_roots(workspace: &Path, home: Option<&Path>) -> SystemPromptLayers {
596 resolve_system_prompt_layers_with_options(PromptResourceOptions {
597 workspace_root: workspace,
598 home_dir: home.map(Path::to_path_buf),
599 })
600 .await
601 }
602
603 async fn find_with_roots(
604 workspace: &Path,
605 home: Option<&Path>,
606 name: &str,
607 ) -> Option<PromptTemplate> {
608 find_prompt_template_with_options(
609 PromptResourceOptions {
610 workspace_root: workspace,
611 home_dir: home.map(Path::to_path_buf),
612 },
613 name,
614 )
615 .await
616 }
617
618 #[tokio::test]
619 #[serial]
620 async fn system_layers_reuse_process_wide_cache_until_cleared() {
621 clear_prompt_resource_caches();
622
623 let workspace = tempfile::TempDir::new().expect("workspace");
624 let home = tempfile::TempDir::new().expect("home");
625 let workspace_prompts = workspace.path().join(PROMPTS_DIR);
626 std::fs::create_dir_all(&workspace_prompts).expect("workspace prompts");
627 std::fs::write(
628 workspace_prompts.join(SYSTEM_PROMPT_FILENAME),
629 "workspace system override",
630 )
631 .expect("write workspace system");
632
633 let first = layers_with_roots(workspace.path(), Some(home.path())).await;
634 assert_eq!(
635 first.override_body.as_deref(),
636 Some("workspace system override")
637 );
638
639 std::fs::remove_file(workspace_prompts.join(SYSTEM_PROMPT_FILENAME))
640 .expect("remove workspace system");
641
642 let second = layers_with_roots(workspace.path(), Some(home.path())).await;
643 assert_eq!(
644 second.override_body.as_deref(),
645 Some("workspace system override")
646 );
647
648 clear_prompt_resource_caches();
649
650 let third = layers_with_roots(workspace.path(), Some(home.path())).await;
651 assert_eq!(third.override_body, None);
652 }
653
654 #[tokio::test]
655 #[serial]
656 async fn prompt_template_discovery_reuses_process_wide_cache_until_cleared() {
657 clear_prompt_resource_caches();
658
659 let workspace = tempfile::TempDir::new().expect("workspace");
660 let home = tempfile::TempDir::new().expect("home");
661 let workspace_templates = workspace.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
662 std::fs::create_dir_all(&workspace_templates).expect("workspace templates");
663 std::fs::write(
664 workspace_templates.join("cache-test.md"),
665 "# Cache test\n\nBody",
666 )
667 .expect("write workspace template");
668
669 let first = discover_with_roots(workspace.path(), Some(home.path())).await;
670 assert!(first.iter().any(|template| template.name == "cache-test"));
671
672 std::fs::remove_file(workspace_templates.join("cache-test.md"))
673 .expect("remove workspace template");
674
675 let second = discover_with_roots(workspace.path(), Some(home.path())).await;
676 assert!(second.iter().any(|template| template.name == "cache-test"));
677 assert!(
678 find_with_roots(workspace.path(), Some(home.path()), "cache-test")
679 .await
680 .is_some()
681 );
682
683 clear_prompt_resource_caches();
684
685 let third = discover_with_roots(workspace.path(), Some(home.path())).await;
686 assert!(!third.iter().any(|template| template.name == "cache-test"));
687 assert!(
688 find_with_roots(workspace.path(), Some(home.path()), "cache-test")
689 .await
690 .is_none()
691 );
692 }
693
694 #[tokio::test]
695 async fn system_layers_prefer_workspace_override_and_append_user_then_workspace() {
696 let workspace = tempfile::TempDir::new().expect("workspace");
697 let home = tempfile::TempDir::new().expect("home");
698
699 let user_prompts = home.path().join(PROMPTS_DIR);
700 let workspace_prompts = workspace.path().join(PROMPTS_DIR);
701 std::fs::create_dir_all(&user_prompts).expect("user prompts");
702 std::fs::create_dir_all(&workspace_prompts).expect("workspace prompts");
703
704 std::fs::write(
705 user_prompts.join(SYSTEM_PROMPT_FILENAME),
706 "user system override",
707 )
708 .expect("write user system");
709 std::fs::write(
710 workspace_prompts.join(SYSTEM_PROMPT_FILENAME),
711 "workspace system override",
712 )
713 .expect("write workspace system");
714 std::fs::write(
715 user_prompts.join(APPEND_SYSTEM_PROMPT_FILENAME),
716 "user append",
717 )
718 .expect("write user append");
719 std::fs::write(
720 workspace_prompts.join(APPEND_SYSTEM_PROMPT_FILENAME),
721 "workspace append",
722 )
723 .expect("write workspace append");
724
725 let layers = layers_with_roots(workspace.path(), Some(home.path())).await;
726 assert_eq!(
727 layers.override_body.as_deref(),
728 Some("workspace system override")
729 );
730 assert_eq!(
731 layers.append_bodies,
732 vec!["user append".to_string(), "workspace append".to_string()]
733 );
734
735 let composed = apply_system_prompt_layers("fallback base", &layers);
736 assert_eq!(
737 composed,
738 "workspace system override\n\nuser append\n\nworkspace append"
739 );
740 }
741
742 #[tokio::test]
743 async fn template_discovery_prefers_workspace_and_derives_descriptions() {
744 let workspace = tempfile::TempDir::new().expect("workspace");
745 let home = tempfile::TempDir::new().expect("home");
746 let user_templates = home.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
747 let workspace_templates = workspace.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
748 std::fs::create_dir_all(&user_templates).expect("user templates");
749 std::fs::create_dir_all(&workspace_templates).expect("workspace templates");
750
751 std::fs::write(
752 user_templates.join("review.md"),
753 "---\ndescription: User review template\n---\nReview $1",
754 )
755 .expect("user review");
756 std::fs::write(
757 workspace_templates.join("review.md"),
758 "# Workspace review\n\nReview workspace $1",
759 )
760 .expect("workspace review");
761 std::fs::write(
762 workspace_templates.join("audit.md"),
763 "First non-empty line becomes description.\n\nAudit $@",
764 )
765 .expect("workspace audit");
766
767 let templates = discover_with_roots(workspace.path(), Some(home.path())).await;
768 assert_eq!(templates.len(), 2);
769 assert_eq!(templates[0].name, "audit");
770 assert_eq!(
771 templates[0].description,
772 "First non-empty line becomes description."
773 );
774 assert_eq!(templates[1].name, "review");
775 assert_eq!(templates[1].description, "Workspace review");
776 assert_eq!(
777 templates[1].body,
778 "# Workspace review\n\nReview workspace $1"
779 );
780 }
781
782 #[tokio::test]
783 async fn direct_template_lookup_uses_workspace_precedence() {
784 let workspace = tempfile::TempDir::new().expect("workspace");
785 let home = tempfile::TempDir::new().expect("home");
786 let user_templates = home.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
787 let workspace_templates = workspace.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
788 std::fs::create_dir_all(&user_templates).expect("user templates");
789 std::fs::create_dir_all(&workspace_templates).expect("workspace templates");
790
791 std::fs::write(user_templates.join("review.md"), "User review body")
792 .expect("user template");
793 std::fs::write(
794 workspace_templates.join("review.md"),
795 "Workspace review body",
796 )
797 .expect("workspace template");
798
799 let template = find_with_roots(workspace.path(), Some(home.path()), "review")
800 .await
801 .expect("template");
802 assert_eq!(template.body, "Workspace review body");
803 }
804
805 #[tokio::test]
806 async fn direct_template_lookup_rejects_unsafe_names() {
807 let workspace = tempfile::TempDir::new().expect("workspace");
808 let home = tempfile::TempDir::new().expect("home");
809
810 let template = find_with_roots(workspace.path(), Some(home.path()), "../escape").await;
811 assert!(template.is_none());
812 }
813
814 #[test]
815 fn template_expansion_supports_positional_and_all_arguments() {
816 let expanded = expand_prompt_template(
817 "Review $1 against $2.\nArgs: $@\nAgain: $ARGUMENTS\nMissing: '$3'",
818 &["src/lib.rs".to_string(), "main".to_string()],
819 );
820
821 assert_eq!(
822 expanded,
823 "Review src/lib.rs against main.\nArgs: src/lib.rs main\nAgain: src/lib.rs main\nMissing: ''"
824 );
825 }
826}