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        newest_mtime(check) > self.source_mtime
90    }
91}
92
93#[derive(Clone)]
94struct CachedSystemPromptLayers {
95    layers: SystemPromptLayers,
96    timestamp: SystemTime,
97    source_mtime: SystemTime,
98}
99
100impl CachedSystemPromptLayers {
101    fn is_expired(&self) -> bool {
102        self.timestamp.elapsed().unwrap_or(PROMPT_RESOURCE_CACHE_TTL) > PROMPT_RESOURCE_CACHE_TTL
103    }
104
105    fn is_source_stale(&self, check_paths: &[&Path]) -> bool {
106        newest_mtime(check_paths.iter().copied()) > self.source_mtime
107    }
108}
109
110fn normalize_cache_path(path: &Path) -> PathBuf {
111    dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
112}
113
114pub(crate) fn user_prompts_dir(home: &Path) -> PathBuf {
115    if dirs::home_dir().as_deref() == Some(home)
116        && let Ok(paths) = VtCodePaths::resolve()
117        && let Ok(prompt_dir) = paths.config_path("prompts")
118    {
119        return prompt_dir;
120    }
121    home.join(PROMPTS_DIR)
122}
123
124/// Newest mtime across `paths`; missing or unreadable paths are ignored
125/// (they contribute `UNIX_EPOCH`, never the maximum).
126fn newest_mtime<'a>(paths: impl IntoIterator<Item = &'a Path>) -> SystemTime {
127    let mut newest = SystemTime::UNIX_EPOCH;
128    for path in paths {
129        if let Ok(metadata) = std::fs::metadata(path) {
130            if let Ok(mtime) = metadata.modified() {
131                newest = newest.max(mtime);
132            }
133        }
134    }
135    newest
136}
137
138fn system_prompt_layers_cache() -> &'static RwLock<HashMap<PromptResourceCacheKey, CachedSystemPromptLayers>> {
139    SYSTEM_PROMPT_LAYERS_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
140}
141
142fn prompt_templates_cache() -> &'static RwLock<HashMap<PromptResourceCacheKey, CachedPromptTemplates>> {
143    PROMPT_TEMPLATES_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
144}
145
146/// Entry contract for the bounded prompt-resource caches: expiry drives
147/// reads, and expiry plus insertion age drive eviction.
148trait ExpirableCacheEntry {
149    fn is_expired(&self) -> bool;
150    fn timestamp(&self) -> SystemTime;
151}
152
153impl ExpirableCacheEntry for CachedSystemPromptLayers {
154    fn is_expired(&self) -> bool {
155        self.is_expired()
156    }
157
158    fn timestamp(&self) -> SystemTime {
159        self.timestamp
160    }
161}
162
163impl ExpirableCacheEntry for CachedPromptTemplates {
164    fn is_expired(&self) -> bool {
165        self.is_expired()
166    }
167
168    fn timestamp(&self) -> SystemTime {
169        self.timestamp
170    }
171}
172
173/// Read-through guard shared by both caches: expired entries miss.
174fn get_cached<K, V>(cache: &RwLock<HashMap<K, V>>, key: &K, what: &str) -> Option<V>
175where
176    K: Eq + std::hash::Hash,
177    V: ExpirableCacheEntry + Clone,
178{
179    match cache.read() {
180        Ok(cache) => cache.get(key).filter(|cached| !cached.is_expired()).cloned(),
181        Err(_) => {
182            warn!("{what} cache lock poisoned while reading cache");
183            None
184        }
185    }
186}
187
188/// Make room for `key` when the cache is full: purge expired entries first,
189/// then evict the oldest. No-op when under budget or when `key` is present.
190fn evict_if_full<K, V>(cache: &mut HashMap<K, V>, key: &K)
191where
192    K: Eq + std::hash::Hash + Clone,
193    V: ExpirableCacheEntry,
194{
195    if cache.len() < PROMPT_RESOURCE_CACHE_MAX_ENTRIES || cache.contains_key(key) {
196        return;
197    }
198    let expired: Vec<K> = cache
199        .iter()
200        .filter(|(_, value)| value.is_expired())
201        .map(|(cache_key, _)| cache_key.clone())
202        .collect();
203    for cache_key in expired {
204        cache.remove(&cache_key);
205    }
206
207    if cache.len() >= PROMPT_RESOURCE_CACHE_MAX_ENTRIES {
208        let oldest_key = cache
209            .iter()
210            .min_by_key(|(_, value)| value.timestamp())
211            .map(|(cache_key, _)| cache_key.clone());
212        if let Some(oldest_key) = oldest_key {
213            cache.remove(&oldest_key);
214        }
215    }
216}
217
218fn get_cached_system_prompt_layers(key: &PromptResourceCacheKey) -> Option<CachedSystemPromptLayers> {
219    get_cached(system_prompt_layers_cache(), key, "system prompt layers")
220}
221
222fn cache_system_prompt_layers(key: PromptResourceCacheKey, layers: &SystemPromptLayers) {
223    match system_prompt_layers_cache().write() {
224        Ok(mut cache) => {
225            evict_if_full(&mut cache, &key);
226
227            let mut source_paths = vec![
228                key.workspace_root.join(PROMPTS_DIR).join(SYSTEM_PROMPT_FILENAME),
229                key.workspace_root.join(PROMPTS_DIR).join(APPEND_SYSTEM_PROMPT_FILENAME),
230            ];
231            if let Some(ref home) = key.home_dir {
232                let prompt_dir = user_prompts_dir(home);
233                source_paths.push(prompt_dir.join(SYSTEM_PROMPT_FILENAME));
234                source_paths.push(prompt_dir.join(APPEND_SYSTEM_PROMPT_FILENAME));
235            }
236            let source_mtime = newest_mtime(source_paths.iter().map(PathBuf::as_path));
237
238            cache.insert(
239                key,
240                CachedSystemPromptLayers {
241                    layers: layers.clone(),
242                    timestamp: SystemTime::now(),
243                    source_mtime,
244                },
245            );
246        }
247        Err(_) => warn!("system prompt layers cache lock poisoned while writing cache"),
248    }
249}
250
251fn get_cached_prompt_templates(key: &PromptResourceCacheKey) -> Option<CachedPromptTemplates> {
252    get_cached(prompt_templates_cache(), key, "prompt templates")
253}
254
255fn cache_prompt_templates(key: PromptResourceCacheKey, templates: &[PromptTemplate]) {
256    match prompt_templates_cache().write() {
257        Ok(mut cache) => {
258            evict_if_full(&mut cache, &key);
259
260            let source_paths = (
261                key.workspace_root.join(PROMPTS_DIR).join(TEMPLATES_DIR),
262                key.home_dir.as_ref().map(|h| user_prompts_dir(h).join(TEMPLATES_DIR)),
263            );
264            let mut mtime_paths = vec![source_paths.0.clone()];
265            if let Some(ref p) = source_paths.1 {
266                mtime_paths.push(p.clone());
267            }
268            let source_mtime = newest_mtime(mtime_paths.iter().map(PathBuf::as_path));
269
270            cache.insert(
271                key,
272                CachedPromptTemplates {
273                    templates: templates.to_vec(),
274                    timestamp: SystemTime::now(),
275                    source_mtime,
276                    source_paths,
277                },
278            );
279        }
280        Err(_) => warn!("prompt templates cache lock poisoned while writing cache"),
281    }
282}
283
284#[cfg(test)]
285fn clear_prompt_resource_caches() {
286    if let Ok(mut cache) = system_prompt_layers_cache().write() {
287        cache.clear();
288    }
289    if let Ok(mut cache) = prompt_templates_cache().write() {
290        cache.clear();
291    }
292}
293
294pub async fn resolve_system_prompt_layers(workspace_root: &Path) -> SystemPromptLayers {
295    resolve_system_prompt_layers_with_options(PromptResourceOptions::new(workspace_root)).await
296}
297
298pub async fn discover_prompt_templates(workspace_root: &Path) -> Vec<PromptTemplate> {
299    discover_prompt_templates_with_options(PromptResourceOptions::new(workspace_root)).await
300}
301
302pub async fn find_prompt_template(workspace_root: &Path, name: &str) -> Option<PromptTemplate> {
303    let normalized = name.trim();
304    if normalized.is_empty() {
305        return None;
306    }
307
308    find_prompt_template_with_options(PromptResourceOptions::new(workspace_root), normalized).await
309}
310
311pub fn apply_system_prompt_layers(base_prompt: &str, layers: &SystemPromptLayers) -> String {
312    let mut prompt = String::new();
313
314    if let Some(override_body) = layers.override_body.as_deref().map(str::trim)
315        && !override_body.is_empty()
316    {
317        prompt.push_str(override_body);
318    } else {
319        prompt.push_str(base_prompt);
320    }
321
322    for append_body in &layers.append_bodies {
323        let trimmed = append_body.trim();
324        if trimmed.is_empty() {
325            continue;
326        }
327        if !prompt.is_empty() {
328            prompt.push_str("\n\n");
329        }
330        prompt.push_str(trimmed);
331    }
332
333    prompt
334}
335
336pub fn expand_prompt_template(body: &str, args: &[String]) -> String {
337    let joined_args = args.join(" ");
338    let mut expanded = String::with_capacity(body.len() + joined_args.len());
339    let chars: Vec<char> = body.chars().collect();
340    let mut index = 0;
341
342    while index < chars.len() {
343        if chars[index] != '$' {
344            expanded.push(chars[index]);
345            index += 1;
346            continue;
347        }
348
349        if index + 1 >= chars.len() {
350            expanded.push('$');
351            index += 1;
352            continue;
353        }
354
355        match chars[index + 1] {
356            '@' => {
357                expanded.push_str(&joined_args);
358                index += 2;
359            }
360            'A' => {
361                const ARGUMENTS_TOKEN: &str = "ARGUMENTS";
362                let remaining: String = chars[index + 1..].iter().collect();
363                if remaining.starts_with(ARGUMENTS_TOKEN) {
364                    expanded.push_str(&joined_args);
365                    index += ARGUMENTS_TOKEN.chars().count() + 1;
366                } else {
367                    expanded.push('$');
368                    index += 1;
369                }
370            }
371            digit if digit.is_ascii_digit() => {
372                let mut cursor = index + 1;
373                while cursor < chars.len() && chars[cursor].is_ascii_digit() {
374                    cursor += 1;
375                }
376                let ordinal: String = chars[index + 1..cursor].iter().collect();
377                let replacement = ordinal
378                    .parse::<usize>()
379                    .ok()
380                    .and_then(|value| value.checked_sub(1))
381                    .and_then(|position| args.get(position))
382                    .map(String::as_str)
383                    .unwrap_or("");
384                expanded.push_str(replacement);
385                index = cursor;
386            }
387            _ => {
388                expanded.push('$');
389                index += 1;
390            }
391        }
392    }
393
394    expanded
395}
396
397impl<'a> PromptResourceOptions<'a> {
398    fn new(workspace_root: &'a Path) -> Self {
399        #[cfg(test)]
400        let home_dir = None;
401
402        #[cfg(not(test))]
403        let home_dir = dirs::home_dir();
404
405        Self { workspace_root, home_dir }
406    }
407}
408
409async fn resolve_system_prompt_layers_with_options(options: PromptResourceOptions<'_>) -> SystemPromptLayers {
410    let cache_key = PromptResourceCacheKey::new(&options);
411    if let Some(cached) = get_cached_system_prompt_layers(&cache_key) {
412        let check_paths = [
413            options.workspace_root.join(PROMPTS_DIR).join(SYSTEM_PROMPT_FILENAME),
414            options
415                .home_dir
416                .as_ref()
417                .map(|h| user_prompts_dir(h).join(SYSTEM_PROMPT_FILENAME))
418                .unwrap_or_default(),
419            options.workspace_root.join(PROMPTS_DIR).join(APPEND_SYSTEM_PROMPT_FILENAME),
420            options
421                .home_dir
422                .as_ref()
423                .map(|h| user_prompts_dir(h).join(APPEND_SYSTEM_PROMPT_FILENAME))
424                .unwrap_or_default(),
425        ];
426        let check_refs: Vec<_> = check_paths.iter().map(|p| p.as_path()).collect();
427        if !cached.is_source_stale(&check_refs) {
428            return cached.layers;
429        }
430    }
431
432    let layers = resolve_system_prompt_layers_uncached(&options).await;
433    cache_system_prompt_layers(cache_key, &layers);
434    layers
435}
436
437async fn resolve_system_prompt_layers_uncached(options: &PromptResourceOptions<'_>) -> SystemPromptLayers {
438    let mut layers = SystemPromptLayers::default();
439
440    let user_system_path = options
441        .home_dir
442        .as_ref()
443        .map(|home| user_prompts_dir(home).join(SYSTEM_PROMPT_FILENAME));
444    let workspace_system_path = options.workspace_root.join(PROMPTS_DIR).join(SYSTEM_PROMPT_FILENAME);
445
446    if let Some(path) = user_system_path.as_ref() {
447        layers.override_body = read_optional_markdown(path).await;
448    }
449
450    if let Some(workspace_override) = read_optional_markdown(&workspace_system_path).await {
451        layers.override_body = Some(workspace_override);
452    }
453
454    if let Some(path) = options
455        .home_dir
456        .as_ref()
457        .map(|home| user_prompts_dir(home).join(APPEND_SYSTEM_PROMPT_FILENAME))
458        && let Some(contents) = read_optional_markdown(&path).await
459    {
460        layers.append_bodies.push(contents);
461    }
462
463    let workspace_append = options.workspace_root.join(PROMPTS_DIR).join(APPEND_SYSTEM_PROMPT_FILENAME);
464    if let Some(contents) = read_optional_markdown(&workspace_append).await {
465        layers.append_bodies.push(contents);
466    }
467
468    layers
469}
470
471async fn discover_prompt_templates_with_options(options: PromptResourceOptions<'_>) -> Vec<PromptTemplate> {
472    let cache_key = PromptResourceCacheKey::new(&options);
473    if let Some(cached) = get_cached_prompt_templates(&cache_key) {
474        if !cached.is_source_stale() {
475            return cached.templates;
476        }
477    }
478
479    let templates = discover_prompt_templates_uncached(&options).await;
480    cache_prompt_templates(cache_key, &templates);
481    templates
482}
483
484async fn discover_prompt_templates_uncached(options: &PromptResourceOptions<'_>) -> Vec<PromptTemplate> {
485    let mut discovered = BTreeMap::new();
486
487    if let Some(home) = options.home_dir.as_deref() {
488        let user_templates = user_prompts_dir(home).join(TEMPLATES_DIR);
489        merge_prompt_templates(&mut discovered, &user_templates, PromptResourceScope::User).await;
490    }
491
492    let workspace_templates = options.workspace_root.join(PROMPTS_DIR).join(TEMPLATES_DIR);
493    merge_prompt_templates(&mut discovered, &workspace_templates, PromptResourceScope::Workspace).await;
494
495    discovered.into_values().collect()
496}
497
498async fn find_prompt_template_with_options(options: PromptResourceOptions<'_>, name: &str) -> Option<PromptTemplate> {
499    if !is_safe_template_name(name) {
500        return None;
501    }
502
503    discover_prompt_templates_with_options(options)
504        .await
505        .into_iter()
506        .find(|template| template.name == name)
507}
508
509async fn merge_prompt_templates(
510    discovered: &mut BTreeMap<String, PromptTemplate>,
511    directory: &Path,
512    scope: PromptResourceScope,
513) {
514    let Ok(mut entries) = fs::read_dir(directory).await else {
515        return;
516    };
517
518    let mut markdown_files = Vec::new();
519    loop {
520        match entries.next_entry().await {
521            Ok(Some(entry)) => {
522                let path = entry.path();
523                if path.extension().and_then(|ext| ext.to_str()) == Some("md") {
524                    markdown_files.push(path);
525                }
526            }
527            Ok(None) => break,
528            Err(err) => {
529                warn!("failed to read prompt templates directory {}: {}", directory.display(), err);
530                break;
531            }
532        }
533    }
534
535    markdown_files.sort();
536
537    for path in markdown_files {
538        let Some(name) = path
539            .file_stem()
540            .and_then(|stem| stem.to_str())
541            .map(str::trim)
542            .filter(|stem| !stem.is_empty())
543            .map(str::to_string)
544        else {
545            continue;
546        };
547
548        match load_prompt_template(&path, name.clone()).await {
549            Some(template) => {
550                if matches!(scope, PromptResourceScope::Workspace) {
551                    discovered.insert(name, template);
552                } else {
553                    discovered.entry(name).or_insert(template);
554                }
555            }
556            None => continue,
557        }
558    }
559}
560
561async fn load_prompt_template(path: &Path, name: String) -> Option<PromptTemplate> {
562    let raw = read_optional_markdown(path).await?;
563    let normalized = normalize_newlines(&raw);
564    let (frontmatter, body) = parse_frontmatter(&normalized);
565    let description = frontmatter
566        .description
567        .filter(|value| !value.trim().is_empty())
568        .unwrap_or_else(|| derive_template_description(&body, &name));
569
570    Some(PromptTemplate {
571        name,
572        description,
573        body: body.trim().to_string(),
574        path: path.to_path_buf(),
575    })
576}
577
578async fn read_optional_markdown(path: &Path) -> Option<String> {
579    match fs::read_to_string(path).await {
580        Ok(contents) => Some(contents),
581        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
582        Err(err) => {
583            warn!("failed to read prompt resource {}: {}", path.display(), err);
584            None
585        }
586    }
587}
588
589fn parse_frontmatter(content: &str) -> (PromptTemplateFrontmatter, String) {
590    if !content.starts_with("---\n") {
591        return (PromptTemplateFrontmatter::default(), content.to_string());
592    }
593
594    let Some(frontmatter_end) = content[4..].find("\n---\n").map(|idx| idx + 4) else {
595        return (PromptTemplateFrontmatter::default(), content.to_string());
596    };
597
598    let yaml = &content[4..frontmatter_end];
599    let body_start = frontmatter_end + 5;
600    let body = if body_start < content.len() {
601        content[body_start..].to_string()
602    } else {
603        String::new()
604    };
605
606    let metadata = match serde_saphyr::from_str::<PromptTemplateFrontmatter>(yaml.trim()) {
607        Ok(value) => value,
608        Err(err) => {
609            warn!("failed to parse prompt template frontmatter: {}", err);
610            PromptTemplateFrontmatter::default()
611        }
612    };
613
614    (metadata, body)
615}
616
617fn derive_template_description(body: &str, name: &str) -> String {
618    for line in body.lines().map(str::trim) {
619        if line.is_empty() {
620            continue;
621        }
622        if let Some(heading) = line.strip_prefix('#') {
623            let trimmed = heading.trim_start_matches('#').trim();
624            if !trimmed.is_empty() {
625                return trimmed.to_string();
626            }
627        }
628        return line.to_string();
629    }
630
631    format!("Prompt template `{name}`")
632}
633
634fn normalize_newlines(content: &str) -> String {
635    content.replace("\r\n", "\n")
636}
637
638fn is_safe_template_name(name: &str) -> bool {
639    !name.is_empty() && !name.contains('/') && !name.contains('\\') && !name.contains("..")
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use serial_test::serial;
646
647    async fn discover_with_roots(workspace: &Path, home: Option<&Path>) -> Vec<PromptTemplate> {
648        discover_prompt_templates_with_options(PromptResourceOptions {
649            workspace_root: workspace,
650            home_dir: home.map(Path::to_path_buf),
651        })
652        .await
653    }
654
655    async fn layers_with_roots(workspace: &Path, home: Option<&Path>) -> SystemPromptLayers {
656        resolve_system_prompt_layers_with_options(PromptResourceOptions {
657            workspace_root: workspace,
658            home_dir: home.map(Path::to_path_buf),
659        })
660        .await
661    }
662
663    async fn find_with_roots(workspace: &Path, home: Option<&Path>, name: &str) -> Option<PromptTemplate> {
664        find_prompt_template_with_options(
665            PromptResourceOptions {
666                workspace_root: workspace,
667                home_dir: home.map(Path::to_path_buf),
668            },
669            name,
670        )
671        .await
672    }
673
674    #[tokio::test]
675    #[serial]
676    async fn system_layers_reuse_process_wide_cache_until_cleared() {
677        clear_prompt_resource_caches();
678
679        let workspace = tempfile::TempDir::new().expect("workspace");
680        let home = tempfile::TempDir::new().expect("home");
681        let workspace_prompts = workspace.path().join(PROMPTS_DIR);
682        std::fs::create_dir_all(&workspace_prompts).expect("workspace prompts");
683        std::fs::write(workspace_prompts.join(SYSTEM_PROMPT_FILENAME), "workspace system override")
684            .expect("write workspace system");
685
686        let first = layers_with_roots(workspace.path(), Some(home.path())).await;
687        assert_eq!(first.override_body.as_deref(), Some("workspace system override"));
688
689        std::fs::remove_file(workspace_prompts.join(SYSTEM_PROMPT_FILENAME)).expect("remove workspace system");
690
691        let second = layers_with_roots(workspace.path(), Some(home.path())).await;
692        assert_eq!(second.override_body.as_deref(), Some("workspace system override"));
693
694        clear_prompt_resource_caches();
695
696        let third = layers_with_roots(workspace.path(), Some(home.path())).await;
697        assert_eq!(third.override_body, None);
698    }
699
700    #[tokio::test]
701    #[serial]
702    async fn prompt_template_discovery_reuses_process_wide_cache_until_cleared() {
703        clear_prompt_resource_caches();
704
705        let workspace = tempfile::TempDir::new().expect("workspace");
706        let home = tempfile::TempDir::new().expect("home");
707        let workspace_templates = workspace.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
708        std::fs::create_dir_all(&workspace_templates).expect("workspace templates");
709        std::fs::write(workspace_templates.join("cache-test.md"), "# Cache test\n\nBody")
710            .expect("write workspace template");
711
712        let first = discover_with_roots(workspace.path(), Some(home.path())).await;
713        assert!(first.iter().any(|template| template.name == "cache-test"));
714
715        std::fs::remove_file(workspace_templates.join("cache-test.md")).expect("remove workspace template");
716
717        let second = discover_with_roots(workspace.path(), Some(home.path())).await;
718        assert!(!second.iter().any(|template| template.name == "cache-test"));
719        assert!(
720            find_with_roots(workspace.path(), Some(home.path()), "cache-test")
721                .await
722                .is_none()
723        );
724
725        clear_prompt_resource_caches();
726
727        let third = discover_with_roots(workspace.path(), Some(home.path())).await;
728        assert!(!third.iter().any(|template| template.name == "cache-test"));
729        assert!(
730            find_with_roots(workspace.path(), Some(home.path()), "cache-test")
731                .await
732                .is_none()
733        );
734    }
735
736    #[tokio::test]
737    async fn system_layers_prefer_workspace_override_and_append_user_then_workspace() {
738        let workspace = tempfile::TempDir::new().expect("workspace");
739        let home = tempfile::TempDir::new().expect("home");
740
741        let user_prompts = home.path().join(PROMPTS_DIR);
742        let workspace_prompts = workspace.path().join(PROMPTS_DIR);
743        std::fs::create_dir_all(&user_prompts).expect("user prompts");
744        std::fs::create_dir_all(&workspace_prompts).expect("workspace prompts");
745
746        std::fs::write(user_prompts.join(SYSTEM_PROMPT_FILENAME), "user system override").expect("write user system");
747        std::fs::write(workspace_prompts.join(SYSTEM_PROMPT_FILENAME), "workspace system override")
748            .expect("write workspace system");
749        std::fs::write(user_prompts.join(APPEND_SYSTEM_PROMPT_FILENAME), "user append").expect("write user append");
750        std::fs::write(workspace_prompts.join(APPEND_SYSTEM_PROMPT_FILENAME), "workspace append")
751            .expect("write workspace append");
752
753        let layers = layers_with_roots(workspace.path(), Some(home.path())).await;
754        assert_eq!(layers.override_body.as_deref(), Some("workspace system override"));
755        assert_eq!(layers.append_bodies, vec!["user append".to_string(), "workspace append".to_string()]);
756
757        let composed = apply_system_prompt_layers("fallback base", &layers);
758        assert_eq!(composed, "workspace system override\n\nuser append\n\nworkspace append");
759    }
760
761    #[tokio::test]
762    async fn template_discovery_prefers_workspace_and_derives_descriptions() {
763        let workspace = tempfile::TempDir::new().expect("workspace");
764        let home = tempfile::TempDir::new().expect("home");
765        let user_templates = home.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
766        let workspace_templates = workspace.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
767        std::fs::create_dir_all(&user_templates).expect("user templates");
768        std::fs::create_dir_all(&workspace_templates).expect("workspace templates");
769
770        std::fs::write(user_templates.join("review.md"), "---\ndescription: User review template\n---\nReview $1")
771            .expect("user review");
772        std::fs::write(workspace_templates.join("review.md"), "# Workspace review\n\nReview workspace $1")
773            .expect("workspace review");
774        std::fs::write(workspace_templates.join("audit.md"), "First non-empty line becomes description.\n\nAudit $@")
775            .expect("workspace audit");
776
777        let templates = discover_with_roots(workspace.path(), Some(home.path())).await;
778        assert_eq!(templates.len(), 2);
779        assert_eq!(templates[0].name, "audit");
780        assert_eq!(templates[0].description, "First non-empty line becomes description.");
781        assert_eq!(templates[1].name, "review");
782        assert_eq!(templates[1].description, "Workspace review");
783        assert_eq!(templates[1].body, "# Workspace review\n\nReview workspace $1");
784    }
785
786    #[tokio::test]
787    async fn direct_template_lookup_uses_workspace_precedence() {
788        let workspace = tempfile::TempDir::new().expect("workspace");
789        let home = tempfile::TempDir::new().expect("home");
790        let user_templates = home.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
791        let workspace_templates = workspace.path().join(PROMPTS_DIR).join(TEMPLATES_DIR);
792        std::fs::create_dir_all(&user_templates).expect("user templates");
793        std::fs::create_dir_all(&workspace_templates).expect("workspace templates");
794
795        std::fs::write(user_templates.join("review.md"), "User review body").expect("user template");
796        std::fs::write(workspace_templates.join("review.md"), "Workspace review body").expect("workspace template");
797
798        let template = find_with_roots(workspace.path(), Some(home.path()), "review")
799            .await
800            .expect("template");
801        assert_eq!(template.body, "Workspace review body");
802    }
803
804    #[tokio::test]
805    async fn direct_template_lookup_rejects_unsafe_names() {
806        let workspace = tempfile::TempDir::new().expect("workspace");
807        let home = tempfile::TempDir::new().expect("home");
808
809        let template = find_with_roots(workspace.path(), Some(home.path()), "../escape").await;
810        assert!(template.is_none());
811    }
812
813    #[test]
814    fn template_expansion_supports_positional_and_all_arguments() {
815        let expanded = expand_prompt_template(
816            "Review $1 against $2.\nArgs: $@\nAgain: $ARGUMENTS\nMissing: '$3'",
817            &["src/lib.rs".to_string(), "main".to_string()],
818        );
819
820        assert_eq!(
821            expanded,
822            "Review src/lib.rs against main.\nArgs: src/lib.rs main\nAgain: src/lib.rs main\nMissing: ''"
823        );
824    }
825
826    struct TestEntry {
827        timestamp: SystemTime,
828        expired: bool,
829    }
830
831    impl ExpirableCacheEntry for TestEntry {
832        fn is_expired(&self) -> bool {
833            self.expired
834        }
835
836        fn timestamp(&self) -> SystemTime {
837            self.timestamp
838        }
839    }
840
841    fn full_live_cache(now: SystemTime) -> HashMap<u32, TestEntry> {
842        (0..PROMPT_RESOURCE_CACHE_MAX_ENTRIES as u32)
843            .map(|i| {
844                (
845                    i,
846                    TestEntry {
847                        // Key 0 is newest, the last key is oldest.
848                        timestamp: now - Duration::from_secs((i + 1) as u64 * 60),
849                        expired: false,
850                    },
851                )
852            })
853            .collect()
854    }
855
856    #[test]
857    fn evict_if_full_removes_oldest_live_entry_at_budget() {
858        let now = SystemTime::now();
859        let mut cache = full_live_cache(now);
860        assert_eq!(cache.len(), PROMPT_RESOURCE_CACHE_MAX_ENTRIES);
861
862        evict_if_full(&mut cache, &u32::MAX);
863        assert_eq!(cache.len(), PROMPT_RESOURCE_CACHE_MAX_ENTRIES - 1);
864        // Oldest (last key) evicted, newest (key 0) retained.
865        assert!(!cache.contains_key(&(PROMPT_RESOURCE_CACHE_MAX_ENTRIES as u32 - 1)));
866        assert!(cache.contains_key(&0));
867    }
868
869    #[test]
870    fn evict_if_full_prefers_expired_over_oldest_live_and_keeps_present_keys() {
871        let now = SystemTime::now();
872        let mut cache = full_live_cache(now);
873        // Expire the newest entry: it must go before the oldest live one.
874        cache.insert(0, TestEntry { timestamp: now, expired: true });
875
876        evict_if_full(&mut cache, &u32::MAX);
877        assert!(!cache.contains_key(&0));
878        assert!(cache.contains_key(&(PROMPT_RESOURCE_CACHE_MAX_ENTRIES as u32 - 1)));
879
880        // A present key at full budget means in-place update: no eviction.
881        let mut full = full_live_cache(now);
882        evict_if_full(&mut full, &0);
883        assert_eq!(full.len(), PROMPT_RESOURCE_CACHE_MAX_ENTRIES);
884
885        // Under budget: no-op for a new key.
886        full.remove(&0);
887        evict_if_full(&mut full, &u32::MAX);
888        assert_eq!(full.len(), PROMPT_RESOURCE_CACHE_MAX_ENTRIES - 1);
889        assert!(full.contains_key(&1));
890    }
891
892    #[test]
893    fn newest_mtime_is_order_independent_and_ignores_missing_paths() {
894        let dir = tempfile::TempDir::new().expect("tempdir");
895        let older = dir.path().join("older.md");
896        let newer = dir.path().join("newer.md");
897        std::fs::write(&older, "old").expect("write older");
898        std::fs::write(&newer, "new").expect("write newer");
899        let base = SystemTime::now();
900        std::fs::File::options()
901            .write(true)
902            .open(&older)
903            .expect("open older")
904            .set_modified(base - Duration::from_secs(100))
905            .expect("set older mtime");
906        std::fs::File::options()
907            .write(true)
908            .open(&newer)
909            .expect("open newer")
910            .set_modified(base)
911            .expect("set newer mtime");
912        let missing = dir.path().join("missing.md");
913
914        // Newer file wins regardless of argument order.
915        let forward = newest_mtime([older.as_path(), newer.as_path()]);
916        let reversed = newest_mtime([newer.as_path(), older.as_path()]);
917        assert_eq!(forward, reversed);
918        assert!(forward > base - Duration::from_secs(100));
919
920        // Missing paths never contribute: same result with or without them,
921        // and a lone missing path degrades to UNIX_EPOCH.
922        assert_eq!(newest_mtime([missing.as_path(), older.as_path(), newer.as_path()]), forward);
923        assert_eq!(newest_mtime([missing.as_path()]), SystemTime::UNIX_EPOCH);
924        assert_eq!(newest_mtime(Vec::<&Path>::new()), SystemTime::UNIX_EPOCH);
925    }
926}