1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::error::Result;
5use crate::storage;
6
7#[derive(Debug, Clone)]
9pub struct AgentsMd {
10 pub path: PathBuf,
11 pub content: String,
12}
13
14#[derive(Debug, Clone)]
16pub struct Skill {
17 pub name: String,
18 pub description: String,
19 pub path: PathBuf,
20}
21
22#[derive(Debug, Clone)]
24pub struct PromptTemplate {
25 pub name: String,
26 pub path: PathBuf,
27 pub content: String,
28}
29
30impl PromptTemplate {
31 pub fn expand(&self, vars: &HashMap<String, String>) -> String {
33 let mut result = self.content.clone();
34 for (key, value) in vars {
35 let placeholder = format!("{{{{{}}}}}", key);
36 result = result.replace(&placeholder, value);
37 }
38 result
39 }
40}
41
42#[derive(Debug, Clone)]
44pub struct SoulDoc {
45 pub path: PathBuf,
46 pub content: String,
47}
48
49pub fn discover_project_soul(cwd: &Path) -> Option<SoulDoc> {
51 let mut dir = Some(cwd);
52 while let Some(d) = dir {
53 let path = storage::project_soul_path(d);
54 if let Ok(content) = std::fs::read_to_string(&path) {
55 return Some(SoulDoc { path, content });
56 }
57 dir = d.parent();
58 }
59 None
60}
61
62pub fn suggested_project_soul_path(cwd: &Path) -> PathBuf {
66 let mut dir = Some(cwd);
67 while let Some(d) = dir {
68 let looks_like_project_root = d.join(".imp").exists()
69 || d.join(".git").exists()
70 || d.join("Cargo.toml").exists()
71 || d.join("package.json").exists()
72 || d.join("pyproject.toml").exists()
73 || d.join("go.mod").exists()
74 || d.join("AGENTS.md").exists()
75 || d.join("CLAUDE.md").exists();
76 if looks_like_project_root {
77 return storage::project_soul_path(d);
78 }
79 dir = d.parent();
80 }
81
82 cwd.join(".imp").join("soul.md")
83}
84
85pub fn discover_soul(cwd: &Path, user_config_dir: &Path) -> Option<SoulDoc> {
91 if let Some(project) = discover_project_soul(cwd) {
92 return Some(project);
93 }
94
95 let global = user_config_dir.join("soul.md");
96 std::fs::read_to_string(&global)
97 .ok()
98 .map(|content| SoulDoc {
99 path: global,
100 content,
101 })
102}
103
104fn global_agents_candidates(user_config_dir: &Path) -> [PathBuf; 3] {
105 [
106 user_config_dir.join("agents.md"),
107 user_config_dir.join("AGENTS.md"),
108 user_config_dir.join("CLAUDE.md"),
109 ]
110}
111
112fn project_agents_candidates(project_dir: &Path) -> [PathBuf; 3] {
113 [
114 storage::project_agents_path(project_dir),
115 project_dir.join("AGENTS.md"),
116 project_dir.join("CLAUDE.md"),
117 ]
118}
119
120fn push_agents_md_if_unique(
121 results: &mut Vec<AgentsMd>,
122 seen_paths: &mut HashSet<PathBuf>,
123 seen_content: &mut HashSet<String>,
124 path: PathBuf,
125) {
126 let Ok(content) = std::fs::read_to_string(&path) else {
127 return;
128 };
129
130 let canonical_path = path.canonicalize().unwrap_or_else(|_| path.clone());
131 if !seen_paths.insert(canonical_path) {
132 return;
133 }
134
135 if !seen_content.insert(content.clone()) {
136 return;
137 }
138
139 results.push(AgentsMd { path, content });
140}
141
142pub fn discover_agents_md(cwd: &Path, user_config_dir: &Path) -> Vec<AgentsMd> {
148 let mut results = Vec::new();
149 let mut seen_paths = HashSet::new();
150 let mut seen_content = HashSet::new();
151
152 for path in global_agents_candidates(user_config_dir) {
153 push_agents_md_if_unique(&mut results, &mut seen_paths, &mut seen_content, path);
154 }
155
156 let mut dir = Some(cwd);
157 while let Some(d) = dir {
158 for path in project_agents_candidates(d) {
159 push_agents_md_if_unique(&mut results, &mut seen_paths, &mut seen_content, path);
160 }
161 dir = d.parent();
162 }
163
164 results
165}
166
167pub fn discover_skills(cwd: &Path, user_config_dir: &Path) -> Vec<Skill> {
169 let mut by_name = HashMap::new();
170 let mut dirs = vec![user_config_dir.join("skills")];
171
172 let mut ancestry = Vec::new();
173 let mut dir = Some(cwd);
174 while let Some(current) = dir {
175 ancestry.push(storage::project_skills_dir(current));
176 dir = current.parent();
177 }
178 ancestry.reverse();
179 dirs.extend(ancestry);
180
181 for dir in &dirs {
182 if let Ok(entries) = std::fs::read_dir(dir) {
183 for entry in entries.flatten() {
184 let skill_dir = entry.path();
185 let skill_file = skill_dir.join("SKILL.md");
186 if skill_file.exists() {
187 if let Ok(content) = std::fs::read_to_string(&skill_file) {
188 let name = skill_dir
189 .file_name()
190 .map(|n| n.to_string_lossy().to_string())
191 .unwrap_or_default();
192 let description = extract_description(&content);
193 by_name.insert(
194 name.clone(),
195 Skill {
196 name,
197 description,
198 path: skill_file,
199 },
200 );
201 }
202 }
203 }
204 }
205 }
206
207 let mut skills: Vec<Skill> = by_name.into_values().collect();
208 skills.sort_by(|a, b| a.name.cmp(&b.name));
209 skills
210}
211
212pub fn discover_prompts(cwd: &Path, user_config_dir: &Path) -> Result<Vec<PromptTemplate>> {
214 let mut prompts = Vec::new();
215
216 let dirs = [
217 user_config_dir.join("prompts"),
218 storage::project_prompts_dir(cwd),
219 ];
220
221 for dir in &dirs {
222 if let Ok(entries) = std::fs::read_dir(dir) {
223 for entry in entries.flatten() {
224 let path = entry.path();
225 if path.extension().is_some_and(|e| e == "md") {
226 if let Ok(content) = std::fs::read_to_string(&path) {
227 let name = path
228 .file_stem()
229 .map(|n| n.to_string_lossy().to_string())
230 .unwrap_or_default();
231 prompts.push(PromptTemplate {
232 name,
233 path,
234 content,
235 });
236 }
237 }
238 }
239 }
240 }
241
242 Ok(prompts)
243}
244
245pub fn extract_description(content: &str) -> String {
247 content
248 .lines()
249 .skip_while(|l| l.starts_with('#') || l.trim().is_empty())
250 .take_while(|l| !l.trim().is_empty())
251 .collect::<Vec<_>>()
252 .join(" ")
253 .chars()
254 .take(200)
255 .collect()
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use std::fs;
262 use tempfile::TempDir;
263
264 #[test]
267 fn resource_discover_soul_uses_global_fallback() {
268 let dir = TempDir::new().unwrap();
269 let user_dir = dir.path().join("config");
270 let cwd = dir.path().join("project");
271 fs::create_dir_all(&user_dir).unwrap();
272 fs::create_dir_all(&cwd).unwrap();
273 fs::write(user_dir.join("soul.md"), "# Soul\n\nglobal soul").unwrap();
274
275 let soul = discover_soul(&cwd, &user_dir).expect("global soul should load");
276 assert!(soul.content.contains("global soul"));
277 assert_eq!(soul.path, user_dir.join("soul.md"));
278 }
279
280 #[test]
281 fn resource_discover_soul_prefers_nearest_project_override() {
282 let dir = TempDir::new().unwrap();
283 let user_dir = dir.path().join("config");
284 let project = dir.path().join("project");
285 let nested = project.join("src").join("deep");
286 fs::create_dir_all(&user_dir).unwrap();
287 fs::create_dir_all(project.join(".imp")).unwrap();
288 fs::create_dir_all(&nested).unwrap();
289 fs::write(user_dir.join("soul.md"), "# Soul\n\nglobal soul").unwrap();
290 fs::write(
291 project.join(".imp").join("soul.md"),
292 "# Soul\n\nproject soul",
293 )
294 .unwrap();
295
296 let soul = discover_soul(&nested, &user_dir).expect("project soul should load");
297 assert!(soul.content.contains("project soul"));
298 assert_eq!(soul.path, project.join(".imp").join("soul.md"));
299 }
300
301 #[test]
302 fn resource_discover_project_soul_walks_up_from_cwd() {
303 let dir = TempDir::new().unwrap();
304 let project = dir.path().join("project");
305 let nested = project.join("src").join("deep");
306 fs::create_dir_all(project.join(".imp")).unwrap();
307 fs::create_dir_all(&nested).unwrap();
308 fs::write(
309 project.join(".imp").join("soul.md"),
310 "# Soul\n\nproject soul",
311 )
312 .unwrap();
313
314 let soul = discover_project_soul(&nested).expect("project soul should load");
315 assert!(soul.content.contains("project soul"));
316 assert_eq!(soul.path, project.join(".imp").join("soul.md"));
317 }
318
319 #[test]
320 fn resource_suggested_project_soul_path_prefers_nearest_projectish_ancestor() {
321 let dir = TempDir::new().unwrap();
322 let project = dir.path().join("project");
323 let nested = project.join("src").join("deep");
324 fs::create_dir_all(&nested).unwrap();
325 fs::write(project.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
326
327 let path = suggested_project_soul_path(&nested);
328 assert_eq!(path, project.join(".imp").join("soul.md"));
329 }
330
331 #[test]
332 fn resource_discover_soul_empty_when_absent() {
333 let dir = TempDir::new().unwrap();
334 let user_dir = dir.path().join("config");
335 let cwd = dir.path().join("project");
336 fs::create_dir_all(&user_dir).unwrap();
337 fs::create_dir_all(&cwd).unwrap();
338
339 assert!(discover_soul(&cwd, &user_dir).is_none());
340 }
341
342 #[test]
345 fn resource_discover_agents_md_from_user_config() {
346 let dir = TempDir::new().unwrap();
347 let user_dir = dir.path().join("config");
348 fs::create_dir_all(&user_dir).unwrap();
349 fs::write(user_dir.join("AGENTS.md"), "# Global rules").unwrap();
350
351 let cwd = dir.path().join("project");
352 fs::create_dir_all(&cwd).unwrap();
353
354 let results = discover_agents_md(&cwd, &user_dir);
355 assert!(results.iter().any(|a| a.content.contains("Global rules")));
356 }
357
358 #[test]
359 fn resource_discover_agents_md_walks_up_from_cwd() {
360 let dir = TempDir::new().unwrap();
361 let user_dir = dir.path().join("config");
362 fs::create_dir_all(&user_dir).unwrap();
363
364 let project = dir.path().join("project");
366 let subdir = project.join("src").join("deep");
367 fs::create_dir_all(&subdir).unwrap();
368 fs::write(project.join("AGENTS.md"), "# Project rules").unwrap();
369
370 let results = discover_agents_md(&subdir, &user_dir);
371 assert!(results.iter().any(|a| a.content.contains("Project rules")));
372 }
373
374 #[test]
375 fn resource_discover_agents_md_finds_claude_md() {
376 let dir = TempDir::new().unwrap();
377 let user_dir = dir.path().join("config");
378 fs::create_dir_all(&user_dir).unwrap();
379 fs::write(user_dir.join("CLAUDE.md"), "# Claude config").unwrap();
380
381 let cwd = dir.path().join("project");
382 fs::create_dir_all(&cwd).unwrap();
383
384 let results = discover_agents_md(&cwd, &user_dir);
385 assert!(results.iter().any(|a| a.content.contains("Claude config")));
386 }
387
388 #[test]
389 fn resource_discover_agents_md_global_first() {
390 let dir = TempDir::new().unwrap();
391 let user_dir = dir.path().join("config");
392 let project = dir.path().join("project");
393 fs::create_dir_all(&user_dir).unwrap();
394 fs::create_dir_all(&project).unwrap();
395
396 fs::write(user_dir.join("AGENTS.md"), "global").unwrap();
397 fs::write(project.join("AGENTS.md"), "project").unwrap();
398
399 let results = discover_agents_md(&project, &user_dir);
400 let global_idx = results.iter().position(|a| a.content == "global").unwrap();
402 let project_idx = results.iter().position(|a| a.content == "project").unwrap();
403 assert!(global_idx < project_idx);
404 }
405
406 #[test]
407 fn resource_discover_agents_md_reads_global_imp_agents_file() {
408 let dir = TempDir::new().unwrap();
409 let user_dir = dir.path().join("config");
410 fs::create_dir_all(&user_dir).unwrap();
411 fs::write(user_dir.join("agents.md"), "global-imp").unwrap();
412
413 let cwd = dir.path().join("project");
414 fs::create_dir_all(&cwd).unwrap();
415
416 let results = discover_agents_md(&cwd, &user_dir);
417 assert!(results.iter().any(|a| a.content == "global-imp"));
418 }
419
420 #[test]
421 fn resource_discover_agents_md_prefers_project_imp_agents_file() {
422 let dir = TempDir::new().unwrap();
423 let user_dir = dir.path().join("config");
424 let project = dir.path().join("project");
425 fs::create_dir_all(&user_dir).unwrap();
426 fs::create_dir_all(project.join(".imp")).unwrap();
427 fs::write(project.join(".imp").join("agents.md"), "project-imp").unwrap();
428 fs::write(project.join("AGENTS.md"), "project-legacy").unwrap();
429
430 let results = discover_agents_md(&project, &user_dir);
431 let canonical_idx = results
432 .iter()
433 .position(|a| a.content == "project-imp")
434 .unwrap();
435 let legacy_idx = results
436 .iter()
437 .position(|a| a.content == "project-legacy")
438 .unwrap();
439 assert!(canonical_idx < legacy_idx);
440 }
441
442 #[test]
443 fn resource_discover_agents_md_dedupes_legacy_global_copy() {
444 let dir = TempDir::new().unwrap();
445 let user_dir = dir.path().join("config");
446 fs::create_dir_all(&user_dir).unwrap();
447 fs::write(user_dir.join("agents.md"), "same global rules").unwrap();
448 fs::write(user_dir.join("AGENTS.md"), "same global rules").unwrap();
449
450 let cwd = dir.path().join("project");
451 fs::create_dir_all(&cwd).unwrap();
452
453 let results = discover_agents_md(&cwd, &user_dir);
454 assert_eq!(
455 results
456 .iter()
457 .filter(|a| a.content == "same global rules")
458 .count(),
459 1
460 );
461 }
462
463 #[test]
464 fn resource_discover_agents_md_dedupes_global_when_home_is_ancestor() {
465 let dir = TempDir::new().unwrap();
466 let user_dir = dir.path().join(".imp");
467 let project = dir.path().join("project");
468 fs::create_dir_all(&user_dir).unwrap();
469 fs::create_dir_all(&project).unwrap();
470 fs::write(user_dir.join("agents.md"), "global rules").unwrap();
471
472 let results = discover_agents_md(&project, &user_dir);
473 assert_eq!(
474 results
475 .iter()
476 .filter(|a| a.content == "global rules")
477 .count(),
478 1
479 );
480 }
481
482 #[test]
483 fn resource_discover_agents_md_keeps_distinct_global_and_project_rules() {
484 let dir = TempDir::new().unwrap();
485 let user_dir = dir.path().join("config");
486 let project = dir.path().join("project");
487 fs::create_dir_all(&user_dir).unwrap();
488 fs::create_dir_all(&project).unwrap();
489 fs::write(user_dir.join("agents.md"), "global rules").unwrap();
490 fs::write(project.join("AGENTS.md"), "project rules").unwrap();
491
492 let results = discover_agents_md(&project, &user_dir);
493 assert!(results.iter().any(|a| a.content == "global rules"));
494 assert!(results.iter().any(|a| a.content == "project rules"));
495 }
496
497 #[test]
498 fn resource_discover_agents_md_empty_when_no_files() {
499 let dir = TempDir::new().unwrap();
500 let user_dir = dir.path().join("config");
501 let cwd = dir.path().join("project");
502 fs::create_dir_all(&user_dir).unwrap();
503 fs::create_dir_all(&cwd).unwrap();
504
505 let results = discover_agents_md(&cwd, &user_dir);
506 assert!(results.is_empty());
507 }
508
509 #[test]
512 fn resource_discover_skills_from_user_dir() {
513 let dir = TempDir::new().unwrap();
514 let user_dir = dir.path().join("config");
515 let skills_dir = user_dir.join("skills").join("my-skill");
516 fs::create_dir_all(&skills_dir).unwrap();
517 fs::write(
518 skills_dir.join("SKILL.md"),
519 "# My Skill\n\nDoes useful things for you.\n",
520 )
521 .unwrap();
522
523 let cwd = dir.path().join("project");
524 fs::create_dir_all(&cwd).unwrap();
525
526 let skills = discover_skills(&cwd, &user_dir);
527 assert_eq!(skills.len(), 1);
528 assert_eq!(skills[0].name, "my-skill");
529 assert!(skills[0].description.contains("useful things"));
530 }
531
532 #[test]
533 fn resource_discover_skills_from_project_dir() {
534 let dir = TempDir::new().unwrap();
535 let user_dir = dir.path().join("config");
536 fs::create_dir_all(&user_dir).unwrap();
537
538 let cwd = dir.path().join("project");
539 let skills_dir = cwd.join(".imp").join("skills").join("project-skill");
540 fs::create_dir_all(&skills_dir).unwrap();
541 fs::write(
542 skills_dir.join("SKILL.md"),
543 "# Project Skill\n\nProject-specific automation.\n",
544 )
545 .unwrap();
546
547 let skills = discover_skills(&cwd, &user_dir);
548 assert_eq!(skills.len(), 1);
549 assert_eq!(skills[0].name, "project-skill");
550 }
551
552 #[test]
553 fn resource_discover_skills_from_both_dirs() {
554 let dir = TempDir::new().unwrap();
555 let user_dir = dir.path().join("config");
556 let user_skills = user_dir.join("skills").join("global-skill");
557 fs::create_dir_all(&user_skills).unwrap();
558 fs::write(user_skills.join("SKILL.md"), "# Global\n\nGlobal skill.\n").unwrap();
559
560 let cwd = dir.path().join("project");
561 let project_skills = cwd.join(".imp").join("skills").join("local-skill");
562 fs::create_dir_all(&project_skills).unwrap();
563 fs::write(project_skills.join("SKILL.md"), "# Local\n\nLocal skill.\n").unwrap();
564
565 let skills = discover_skills(&cwd, &user_dir);
566 assert_eq!(skills.len(), 2);
567 let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
568 assert!(names.contains(&"global-skill"));
569 assert!(names.contains(&"local-skill"));
570 }
571
572 #[test]
573 fn resource_discover_skills_walks_up_from_cwd() {
574 let dir = TempDir::new().unwrap();
575 let user_dir = dir.path().join("config");
576 fs::create_dir_all(&user_dir).unwrap();
577
578 let project = dir.path().join("project");
579 let nested = project.join("src").join("deep");
580 let skills_dir = project.join(".imp").join("skills").join("project-skill");
581 fs::create_dir_all(&skills_dir).unwrap();
582 fs::create_dir_all(&nested).unwrap();
583 fs::write(
584 skills_dir.join("SKILL.md"),
585 "# Project Skill\n\nProject-specific automation.\n",
586 )
587 .unwrap();
588
589 let skills = discover_skills(&nested, &user_dir);
590 assert_eq!(skills.len(), 1);
591 assert_eq!(skills[0].name, "project-skill");
592 }
593
594 #[test]
595 fn resource_discover_skills_project_overrides_user_by_name() {
596 let dir = TempDir::new().unwrap();
597 let user_dir = dir.path().join("config");
598 let user_skill = user_dir.join("skills").join("mana");
599 fs::create_dir_all(&user_skill).unwrap();
600 fs::write(user_skill.join("SKILL.md"), "# Mana\n\nUser version.\n").unwrap();
601
602 let project = dir.path().join("project");
603 let project_skill = project.join(".imp").join("skills").join("mana");
604 fs::create_dir_all(&project_skill).unwrap();
605 fs::write(
606 project_skill.join("SKILL.md"),
607 "# Mana\n\nProject version.\n",
608 )
609 .unwrap();
610
611 let skills = discover_skills(&project, &user_dir);
612 assert_eq!(skills.len(), 1);
613 assert_eq!(skills[0].name, "mana");
614 assert!(skills[0].description.contains("Project version"));
615 assert_eq!(skills[0].path, project_skill.join("SKILL.md"));
616 }
617
618 #[test]
619 fn resource_discover_skills_skips_dirs_without_skill_md() {
620 let dir = TempDir::new().unwrap();
621 let user_dir = dir.path().join("config");
622 let skills_dir = user_dir.join("skills").join("incomplete-skill");
623 fs::create_dir_all(&skills_dir).unwrap();
624 fs::write(skills_dir.join("README.md"), "not a skill").unwrap();
626
627 let cwd = dir.path().join("project");
628 fs::create_dir_all(&cwd).unwrap();
629
630 let skills = discover_skills(&cwd, &user_dir);
631 assert!(skills.is_empty());
632 }
633
634 #[test]
635 fn resource_discover_skills_empty_when_no_dirs() {
636 let dir = TempDir::new().unwrap();
637 let user_dir = dir.path().join("config");
638 let cwd = dir.path().join("project");
639 fs::create_dir_all(&user_dir).unwrap();
640 fs::create_dir_all(&cwd).unwrap();
641
642 let skills = discover_skills(&cwd, &user_dir);
643 assert!(skills.is_empty());
644 }
645
646 #[test]
649 fn resource_discover_prompts_from_user_dir() {
650 let dir = TempDir::new().unwrap();
651 let user_dir = dir.path().join("config");
652 let prompts_dir = user_dir.join("prompts");
653 fs::create_dir_all(&prompts_dir).unwrap();
654 fs::write(prompts_dir.join("review.md"), "Review this code: {{code}}").unwrap();
655
656 let cwd = dir.path().join("project");
657 fs::create_dir_all(&cwd).unwrap();
658
659 let prompts = discover_prompts(&cwd, &user_dir).unwrap();
660 assert_eq!(prompts.len(), 1);
661 assert_eq!(prompts[0].name, "review");
662 assert!(prompts[0].content.contains("{{code}}"));
663 }
664
665 #[test]
666 fn resource_discover_prompts_from_project_dir() {
667 let dir = TempDir::new().unwrap();
668 let user_dir = dir.path().join("config");
669 fs::create_dir_all(&user_dir).unwrap();
670
671 let cwd = dir.path().join("project");
672 let prompts_dir = cwd.join(".imp").join("prompts");
673 fs::create_dir_all(&prompts_dir).unwrap();
674 fs::write(
675 prompts_dir.join("deploy.md"),
676 "Deploy {{service}} to {{env}}",
677 )
678 .unwrap();
679
680 let prompts = discover_prompts(&cwd, &user_dir).unwrap();
681 assert_eq!(prompts.len(), 1);
682 assert_eq!(prompts[0].name, "deploy");
683 }
684
685 #[test]
686 fn resource_discover_prompts_ignores_non_md_files() {
687 let dir = TempDir::new().unwrap();
688 let user_dir = dir.path().join("config");
689 let prompts_dir = user_dir.join("prompts");
690 fs::create_dir_all(&prompts_dir).unwrap();
691 fs::write(prompts_dir.join("valid.md"), "prompt content").unwrap();
692 fs::write(prompts_dir.join("ignored.txt"), "not a prompt").unwrap();
693 fs::write(prompts_dir.join("also_ignored.toml"), "nope").unwrap();
694
695 let cwd = dir.path().join("project");
696 fs::create_dir_all(&cwd).unwrap();
697
698 let prompts = discover_prompts(&cwd, &user_dir).unwrap();
699 assert_eq!(prompts.len(), 1);
700 assert_eq!(prompts[0].name, "valid");
701 }
702
703 #[test]
704 fn resource_discover_prompts_empty_when_no_dirs() {
705 let dir = TempDir::new().unwrap();
706 let user_dir = dir.path().join("config");
707 let cwd = dir.path().join("project");
708 fs::create_dir_all(&user_dir).unwrap();
709 fs::create_dir_all(&cwd).unwrap();
710
711 let prompts = discover_prompts(&cwd, &user_dir).unwrap();
712 assert!(prompts.is_empty());
713 }
714
715 #[test]
718 fn resource_prompt_template_expand_variables() {
719 let template = PromptTemplate {
720 name: "test".into(),
721 path: PathBuf::from("test.md"),
722 content: "Hello {{name}}, welcome to {{project}}!".into(),
723 };
724
725 let mut vars = HashMap::new();
726 vars.insert("name".into(), "Alice".into());
727 vars.insert("project".into(), "imp".into());
728
729 let result = template.expand(&vars);
730 assert_eq!(result, "Hello Alice, welcome to imp!");
731 }
732
733 #[test]
734 fn resource_prompt_template_expand_missing_variable_left_as_is() {
735 let template = PromptTemplate {
736 name: "test".into(),
737 path: PathBuf::from("test.md"),
738 content: "Hello {{name}}, your role is {{role}}.".into(),
739 };
740
741 let mut vars = HashMap::new();
742 vars.insert("name".into(), "Bob".into());
743 let result = template.expand(&vars);
746 assert_eq!(result, "Hello Bob, your role is {{role}}.");
747 }
748
749 #[test]
750 fn resource_prompt_template_expand_empty_vars() {
751 let template = PromptTemplate {
752 name: "test".into(),
753 path: PathBuf::from("test.md"),
754 content: "No variables here.".into(),
755 };
756
757 let vars = HashMap::new();
758 let result = template.expand(&vars);
759 assert_eq!(result, "No variables here.");
760 }
761
762 #[test]
763 fn resource_prompt_template_expand_repeated_variable() {
764 let template = PromptTemplate {
765 name: "test".into(),
766 path: PathBuf::from("test.md"),
767 content: "{{x}} and {{x}} again".into(),
768 };
769
770 let mut vars = HashMap::new();
771 vars.insert("x".into(), "hello".into());
772
773 let result = template.expand(&vars);
774 assert_eq!(result, "hello and hello again");
775 }
776
777 #[test]
780 fn resource_extract_description_skips_headings() {
781 let content = "# Title\n\nThis is the description.\nMore text here.\n\n## Section";
782 let desc = extract_description(content);
783 assert_eq!(desc, "This is the description. More text here.");
784 }
785
786 #[test]
787 fn resource_extract_description_empty_content() {
788 assert_eq!(extract_description(""), "");
789 }
790
791 #[test]
792 fn resource_extract_description_truncates_at_200_chars() {
793 let long_line = "A".repeat(250);
794 let content = format!("# Title\n\n{}", long_line);
795 let desc = extract_description(&content);
796 assert_eq!(desc.len(), 200);
797 }
798}