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 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}