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