1use std::collections::{BTreeMap, HashMap};
4use std::path::{Path, PathBuf};
5use std::sync::{OnceLock, RwLock};
6use std::time::{Duration, SystemTime};
7
8use serde::Deserialize;
9use tokio::fs;
10use tracing::warn;
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
124fn 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
146trait 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
173fn 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
188fn 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 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 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 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 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 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 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 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}