Skip to main content

vtcode_core/prompts/
resources.rs

1//! Shared prompt resource discovery for system prompt layers and prompt templates.
2
3use 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}