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
258pub fn strip_frontmatter(content: &str) -> &str {
260 let Some(rest) = content.strip_prefix("---\n") else {
261 return content;
262 };
263
264 match rest.find("\n---") {
265 Some(end) => rest[end + "\n---".len()..].trim_start_matches(['\n', '\r']),
266 None => content,
267 }
268}
269
270pub fn render_skill_invocation(name: &str, content: &str, args: &str) -> String {
272 let body = strip_frontmatter(content).trim();
273 let args = args.trim();
274 let body = if args.is_empty() {
275 body.to_string()
276 } else if body.contains("$ARGUMENTS") {
277 body.replace("$ARGUMENTS", args)
278 } else {
279 format!("{body}\n\nARGUMENTS: {args}")
280 };
281
282 format!("Use the `{name}` skill.\n\n{body}")
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use std::fs;
289 use tempfile::TempDir;
290
291 #[test]
294 fn resource_discover_soul_uses_global_fallback() {
295 let dir = TempDir::new().unwrap();
296 let user_dir = dir.path().join("config");
297 let cwd = dir.path().join("project");
298 fs::create_dir_all(&user_dir).unwrap();
299 fs::create_dir_all(&cwd).unwrap();
300 fs::write(user_dir.join("soul.md"), "# Soul\n\nglobal soul").unwrap();
301
302 let soul = discover_soul(&cwd, &user_dir).expect("global soul should load");
303 assert!(soul.content.contains("global soul"));
304 assert_eq!(soul.path, user_dir.join("soul.md"));
305 }
306
307 #[test]
308 fn resource_discover_soul_prefers_nearest_project_override() {
309 let dir = TempDir::new().unwrap();
310 let user_dir = dir.path().join("config");
311 let project = dir.path().join("project");
312 let nested = project.join("src").join("deep");
313 fs::create_dir_all(&user_dir).unwrap();
314 fs::create_dir_all(project.join(".imp")).unwrap();
315 fs::create_dir_all(&nested).unwrap();
316 fs::write(user_dir.join("soul.md"), "# Soul\n\nglobal soul").unwrap();
317 fs::write(
318 project.join(".imp").join("soul.md"),
319 "# Soul\n\nproject soul",
320 )
321 .unwrap();
322
323 let soul = discover_soul(&nested, &user_dir).expect("project soul should load");
324 assert!(soul.content.contains("project soul"));
325 assert_eq!(soul.path, project.join(".imp").join("soul.md"));
326 }
327
328 #[test]
329 fn resource_discover_project_soul_walks_up_from_cwd() {
330 let dir = TempDir::new().unwrap();
331 let project = dir.path().join("project");
332 let nested = project.join("src").join("deep");
333 fs::create_dir_all(project.join(".imp")).unwrap();
334 fs::create_dir_all(&nested).unwrap();
335 fs::write(
336 project.join(".imp").join("soul.md"),
337 "# Soul\n\nproject soul",
338 )
339 .unwrap();
340
341 let soul = discover_project_soul(&nested).expect("project soul should load");
342 assert!(soul.content.contains("project soul"));
343 assert_eq!(soul.path, project.join(".imp").join("soul.md"));
344 }
345
346 #[test]
347 fn resource_suggested_project_soul_path_prefers_nearest_projectish_ancestor() {
348 let dir = TempDir::new().unwrap();
349 let project = dir.path().join("project");
350 let nested = project.join("src").join("deep");
351 fs::create_dir_all(&nested).unwrap();
352 fs::write(project.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
353
354 let path = suggested_project_soul_path(&nested);
355 assert_eq!(path, project.join(".imp").join("soul.md"));
356 }
357
358 #[test]
359 fn resource_discover_soul_empty_when_absent() {
360 let dir = TempDir::new().unwrap();
361 let user_dir = dir.path().join("config");
362 let cwd = dir.path().join("project");
363 fs::create_dir_all(&user_dir).unwrap();
364 fs::create_dir_all(&cwd).unwrap();
365
366 assert!(discover_soul(&cwd, &user_dir).is_none());
367 }
368
369 #[test]
372 fn resource_discover_agents_md_from_user_config() {
373 let dir = TempDir::new().unwrap();
374 let user_dir = dir.path().join("config");
375 fs::create_dir_all(&user_dir).unwrap();
376 fs::write(user_dir.join("AGENTS.md"), "# Global rules").unwrap();
377
378 let cwd = dir.path().join("project");
379 fs::create_dir_all(&cwd).unwrap();
380
381 let results = discover_agents_md(&cwd, &user_dir);
382 assert!(results.iter().any(|a| a.content.contains("Global rules")));
383 }
384
385 #[test]
386 fn resource_discover_agents_md_walks_up_from_cwd() {
387 let dir = TempDir::new().unwrap();
388 let user_dir = dir.path().join("config");
389 fs::create_dir_all(&user_dir).unwrap();
390
391 let project = dir.path().join("project");
393 let subdir = project.join("src").join("deep");
394 fs::create_dir_all(&subdir).unwrap();
395 fs::write(project.join("AGENTS.md"), "# Project rules").unwrap();
396
397 let results = discover_agents_md(&subdir, &user_dir);
398 assert!(results.iter().any(|a| a.content.contains("Project rules")));
399 }
400
401 #[test]
402 fn resource_discover_agents_md_finds_claude_md() {
403 let dir = TempDir::new().unwrap();
404 let user_dir = dir.path().join("config");
405 fs::create_dir_all(&user_dir).unwrap();
406 fs::write(user_dir.join("CLAUDE.md"), "# Claude config").unwrap();
407
408 let cwd = dir.path().join("project");
409 fs::create_dir_all(&cwd).unwrap();
410
411 let results = discover_agents_md(&cwd, &user_dir);
412 assert!(results.iter().any(|a| a.content.contains("Claude config")));
413 }
414
415 #[test]
416 fn resource_discover_agents_md_global_first() {
417 let dir = TempDir::new().unwrap();
418 let user_dir = dir.path().join("config");
419 let project = dir.path().join("project");
420 fs::create_dir_all(&user_dir).unwrap();
421 fs::create_dir_all(&project).unwrap();
422
423 fs::write(user_dir.join("AGENTS.md"), "global").unwrap();
424 fs::write(project.join("AGENTS.md"), "project").unwrap();
425
426 let results = discover_agents_md(&project, &user_dir);
427 let global_idx = results.iter().position(|a| a.content == "global").unwrap();
429 let project_idx = results.iter().position(|a| a.content == "project").unwrap();
430 assert!(global_idx < project_idx);
431 }
432
433 #[test]
434 fn resource_discover_agents_md_reads_global_imp_agents_file() {
435 let dir = TempDir::new().unwrap();
436 let user_dir = dir.path().join("config");
437 fs::create_dir_all(&user_dir).unwrap();
438 fs::write(user_dir.join("agents.md"), "global-imp").unwrap();
439
440 let cwd = dir.path().join("project");
441 fs::create_dir_all(&cwd).unwrap();
442
443 let results = discover_agents_md(&cwd, &user_dir);
444 assert!(results.iter().any(|a| a.content == "global-imp"));
445 }
446
447 #[test]
448 fn resource_discover_agents_md_prefers_project_imp_agents_file() {
449 let dir = TempDir::new().unwrap();
450 let user_dir = dir.path().join("config");
451 let project = dir.path().join("project");
452 fs::create_dir_all(&user_dir).unwrap();
453 fs::create_dir_all(project.join(".imp")).unwrap();
454 fs::write(project.join(".imp").join("agents.md"), "project-imp").unwrap();
455 fs::write(project.join("AGENTS.md"), "project-legacy").unwrap();
456
457 let results = discover_agents_md(&project, &user_dir);
458 let canonical_idx = results
459 .iter()
460 .position(|a| a.content == "project-imp")
461 .unwrap();
462 let legacy_idx = results
463 .iter()
464 .position(|a| a.content == "project-legacy")
465 .unwrap();
466 assert!(canonical_idx < legacy_idx);
467 }
468
469 #[test]
470 fn resource_discover_agents_md_dedupes_legacy_global_copy() {
471 let dir = TempDir::new().unwrap();
472 let user_dir = dir.path().join("config");
473 fs::create_dir_all(&user_dir).unwrap();
474 fs::write(user_dir.join("agents.md"), "same global rules").unwrap();
475 fs::write(user_dir.join("AGENTS.md"), "same global rules").unwrap();
476
477 let cwd = dir.path().join("project");
478 fs::create_dir_all(&cwd).unwrap();
479
480 let results = discover_agents_md(&cwd, &user_dir);
481 assert_eq!(
482 results
483 .iter()
484 .filter(|a| a.content == "same global rules")
485 .count(),
486 1
487 );
488 }
489
490 #[test]
491 fn resource_discover_agents_md_dedupes_global_when_home_is_ancestor() {
492 let dir = TempDir::new().unwrap();
493 let user_dir = dir.path().join(".imp");
494 let project = dir.path().join("project");
495 fs::create_dir_all(&user_dir).unwrap();
496 fs::create_dir_all(&project).unwrap();
497 fs::write(user_dir.join("agents.md"), "global rules").unwrap();
498
499 let results = discover_agents_md(&project, &user_dir);
500 assert_eq!(
501 results
502 .iter()
503 .filter(|a| a.content == "global rules")
504 .count(),
505 1
506 );
507 }
508
509 #[test]
510 fn resource_discover_agents_md_keeps_distinct_global_and_project_rules() {
511 let dir = TempDir::new().unwrap();
512 let user_dir = dir.path().join("config");
513 let project = dir.path().join("project");
514 fs::create_dir_all(&user_dir).unwrap();
515 fs::create_dir_all(&project).unwrap();
516 fs::write(user_dir.join("agents.md"), "global rules").unwrap();
517 fs::write(project.join("AGENTS.md"), "project rules").unwrap();
518
519 let results = discover_agents_md(&project, &user_dir);
520 assert!(results.iter().any(|a| a.content == "global rules"));
521 assert!(results.iter().any(|a| a.content == "project rules"));
522 }
523
524 #[test]
525 fn resource_discover_agents_md_empty_when_no_files() {
526 let dir = TempDir::new().unwrap();
527 let user_dir = dir.path().join("config");
528 let cwd = dir.path().join("project");
529 fs::create_dir_all(&user_dir).unwrap();
530 fs::create_dir_all(&cwd).unwrap();
531
532 let results = discover_agents_md(&cwd, &user_dir);
533 assert!(results.is_empty());
534 }
535
536 #[test]
539 fn resource_discover_skills_from_user_dir() {
540 let dir = TempDir::new().unwrap();
541 let user_dir = dir.path().join("config");
542 let skills_dir = user_dir.join("skills").join("my-skill");
543 fs::create_dir_all(&skills_dir).unwrap();
544 fs::write(
545 skills_dir.join("SKILL.md"),
546 "# My Skill\n\nDoes useful things for you.\n",
547 )
548 .unwrap();
549
550 let cwd = dir.path().join("project");
551 fs::create_dir_all(&cwd).unwrap();
552
553 let skills = discover_skills(&cwd, &user_dir);
554 assert_eq!(skills.len(), 1);
555 assert_eq!(skills[0].name, "my-skill");
556 assert!(skills[0].description.contains("useful things"));
557 }
558
559 #[test]
560 fn resource_discover_skills_from_project_dir() {
561 let dir = TempDir::new().unwrap();
562 let user_dir = dir.path().join("config");
563 fs::create_dir_all(&user_dir).unwrap();
564
565 let cwd = dir.path().join("project");
566 let skills_dir = cwd.join(".imp").join("skills").join("project-skill");
567 fs::create_dir_all(&skills_dir).unwrap();
568 fs::write(
569 skills_dir.join("SKILL.md"),
570 "# Project Skill\n\nProject-specific automation.\n",
571 )
572 .unwrap();
573
574 let skills = discover_skills(&cwd, &user_dir);
575 assert_eq!(skills.len(), 1);
576 assert_eq!(skills[0].name, "project-skill");
577 }
578
579 #[test]
580 fn resource_discover_skills_from_both_dirs() {
581 let dir = TempDir::new().unwrap();
582 let user_dir = dir.path().join("config");
583 let user_skills = user_dir.join("skills").join("global-skill");
584 fs::create_dir_all(&user_skills).unwrap();
585 fs::write(user_skills.join("SKILL.md"), "# Global\n\nGlobal skill.\n").unwrap();
586
587 let cwd = dir.path().join("project");
588 let project_skills = cwd.join(".imp").join("skills").join("local-skill");
589 fs::create_dir_all(&project_skills).unwrap();
590 fs::write(project_skills.join("SKILL.md"), "# Local\n\nLocal skill.\n").unwrap();
591
592 let skills = discover_skills(&cwd, &user_dir);
593 assert_eq!(skills.len(), 2);
594 let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
595 assert!(names.contains(&"global-skill"));
596 assert!(names.contains(&"local-skill"));
597 }
598
599 #[test]
600 fn resource_discover_skills_walks_up_from_cwd() {
601 let dir = TempDir::new().unwrap();
602 let user_dir = dir.path().join("config");
603 fs::create_dir_all(&user_dir).unwrap();
604
605 let project = dir.path().join("project");
606 let nested = project.join("src").join("deep");
607 let skills_dir = project.join(".imp").join("skills").join("project-skill");
608 fs::create_dir_all(&skills_dir).unwrap();
609 fs::create_dir_all(&nested).unwrap();
610 fs::write(
611 skills_dir.join("SKILL.md"),
612 "# Project Skill\n\nProject-specific automation.\n",
613 )
614 .unwrap();
615
616 let skills = discover_skills(&nested, &user_dir);
617 assert_eq!(skills.len(), 1);
618 assert_eq!(skills[0].name, "project-skill");
619 }
620
621 #[test]
622 fn resource_discover_skills_project_overrides_user_by_name() {
623 let dir = TempDir::new().unwrap();
624 let user_dir = dir.path().join("config");
625 let user_skill = user_dir.join("skills").join("mana");
626 fs::create_dir_all(&user_skill).unwrap();
627 fs::write(user_skill.join("SKILL.md"), "# Mana\n\nUser version.\n").unwrap();
628
629 let project = dir.path().join("project");
630 let project_skill = project.join(".imp").join("skills").join("mana");
631 fs::create_dir_all(&project_skill).unwrap();
632 fs::write(
633 project_skill.join("SKILL.md"),
634 "# Mana\n\nProject version.\n",
635 )
636 .unwrap();
637
638 let skills = discover_skills(&project, &user_dir);
639 assert_eq!(skills.len(), 1);
640 assert_eq!(skills[0].name, "mana");
641 assert!(skills[0].description.contains("Project version"));
642 assert_eq!(skills[0].path, project_skill.join("SKILL.md"));
643 }
644
645 #[test]
646 fn resource_discover_skills_skips_dirs_without_skill_md() {
647 let dir = TempDir::new().unwrap();
648 let user_dir = dir.path().join("config");
649 let skills_dir = user_dir.join("skills").join("incomplete-skill");
650 fs::create_dir_all(&skills_dir).unwrap();
651 fs::write(skills_dir.join("README.md"), "not a skill").unwrap();
653
654 let cwd = dir.path().join("project");
655 fs::create_dir_all(&cwd).unwrap();
656
657 let skills = discover_skills(&cwd, &user_dir);
658 assert!(skills.is_empty());
659 }
660
661 #[test]
662 fn resource_discover_skills_empty_when_no_dirs() {
663 let dir = TempDir::new().unwrap();
664 let user_dir = dir.path().join("config");
665 let cwd = dir.path().join("project");
666 fs::create_dir_all(&user_dir).unwrap();
667 fs::create_dir_all(&cwd).unwrap();
668
669 let skills = discover_skills(&cwd, &user_dir);
670 assert!(skills.is_empty());
671 }
672
673 #[test]
676 fn resource_discover_prompts_from_user_dir() {
677 let dir = TempDir::new().unwrap();
678 let user_dir = dir.path().join("config");
679 let prompts_dir = user_dir.join("prompts");
680 fs::create_dir_all(&prompts_dir).unwrap();
681 fs::write(prompts_dir.join("review.md"), "Review this code: {{code}}").unwrap();
682
683 let cwd = dir.path().join("project");
684 fs::create_dir_all(&cwd).unwrap();
685
686 let prompts = discover_prompts(&cwd, &user_dir).unwrap();
687 assert_eq!(prompts.len(), 1);
688 assert_eq!(prompts[0].name, "review");
689 assert!(prompts[0].content.contains("{{code}}"));
690 }
691
692 #[test]
693 fn resource_discover_prompts_from_project_dir() {
694 let dir = TempDir::new().unwrap();
695 let user_dir = dir.path().join("config");
696 fs::create_dir_all(&user_dir).unwrap();
697
698 let cwd = dir.path().join("project");
699 let prompts_dir = cwd.join(".imp").join("prompts");
700 fs::create_dir_all(&prompts_dir).unwrap();
701 fs::write(
702 prompts_dir.join("deploy.md"),
703 "Deploy {{service}} to {{env}}",
704 )
705 .unwrap();
706
707 let prompts = discover_prompts(&cwd, &user_dir).unwrap();
708 assert_eq!(prompts.len(), 1);
709 assert_eq!(prompts[0].name, "deploy");
710 }
711
712 #[test]
713 fn resource_discover_prompts_ignores_non_md_files() {
714 let dir = TempDir::new().unwrap();
715 let user_dir = dir.path().join("config");
716 let prompts_dir = user_dir.join("prompts");
717 fs::create_dir_all(&prompts_dir).unwrap();
718 fs::write(prompts_dir.join("valid.md"), "prompt content").unwrap();
719 fs::write(prompts_dir.join("ignored.txt"), "not a prompt").unwrap();
720 fs::write(prompts_dir.join("also_ignored.toml"), "nope").unwrap();
721
722 let cwd = dir.path().join("project");
723 fs::create_dir_all(&cwd).unwrap();
724
725 let prompts = discover_prompts(&cwd, &user_dir).unwrap();
726 assert_eq!(prompts.len(), 1);
727 assert_eq!(prompts[0].name, "valid");
728 }
729
730 #[test]
731 fn resource_discover_prompts_empty_when_no_dirs() {
732 let dir = TempDir::new().unwrap();
733 let user_dir = dir.path().join("config");
734 let cwd = dir.path().join("project");
735 fs::create_dir_all(&user_dir).unwrap();
736 fs::create_dir_all(&cwd).unwrap();
737
738 let prompts = discover_prompts(&cwd, &user_dir).unwrap();
739 assert!(prompts.is_empty());
740 }
741
742 #[test]
745 fn resource_prompt_template_expand_variables() {
746 let template = PromptTemplate {
747 name: "test".into(),
748 path: PathBuf::from("test.md"),
749 content: "Hello {{name}}, welcome to {{project}}!".into(),
750 };
751
752 let mut vars = HashMap::new();
753 vars.insert("name".into(), "Alice".into());
754 vars.insert("project".into(), "imp".into());
755
756 let result = template.expand(&vars);
757 assert_eq!(result, "Hello Alice, welcome to imp!");
758 }
759
760 #[test]
761 fn resource_prompt_template_expand_missing_variable_left_as_is() {
762 let template = PromptTemplate {
763 name: "test".into(),
764 path: PathBuf::from("test.md"),
765 content: "Hello {{name}}, your role is {{role}}.".into(),
766 };
767
768 let mut vars = HashMap::new();
769 vars.insert("name".into(), "Bob".into());
770 let result = template.expand(&vars);
773 assert_eq!(result, "Hello Bob, your role is {{role}}.");
774 }
775
776 #[test]
777 fn resource_prompt_template_expand_empty_vars() {
778 let template = PromptTemplate {
779 name: "test".into(),
780 path: PathBuf::from("test.md"),
781 content: "No variables here.".into(),
782 };
783
784 let vars = HashMap::new();
785 let result = template.expand(&vars);
786 assert_eq!(result, "No variables here.");
787 }
788
789 #[test]
790 fn resource_prompt_template_expand_repeated_variable() {
791 let template = PromptTemplate {
792 name: "test".into(),
793 path: PathBuf::from("test.md"),
794 content: "{{x}} and {{x}} again".into(),
795 };
796
797 let mut vars = HashMap::new();
798 vars.insert("x".into(), "hello".into());
799
800 let result = template.expand(&vars);
801 assert_eq!(result, "hello and hello again");
802 }
803
804 #[test]
807 fn resource_extract_description_skips_headings() {
808 let content = "# Title\n\nThis is the description.\nMore text here.\n\n## Section";
809 let desc = extract_description(content);
810 assert_eq!(desc, "This is the description. More text here.");
811 }
812
813 #[test]
814 fn resource_extract_description_empty_content() {
815 assert_eq!(extract_description(""), "");
816 }
817
818 #[test]
819 fn resource_extract_description_truncates_at_200_chars() {
820 let long_line = "A".repeat(250);
821 let content = format!("# Title\n\n{}", long_line);
822 let desc = extract_description(&content);
823 assert_eq!(desc.len(), 200);
824 }
825}