Skip to main content

imp_core/
resources.rs

1use std::collections::{HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use crate::error::Result;
5use crate::storage;
6
7/// Discovered AGENTS.md content.
8#[derive(Debug, Clone)]
9pub struct AgentsMd {
10    pub path: PathBuf,
11    pub content: String,
12}
13
14/// Discovered skill.
15#[derive(Debug, Clone)]
16pub struct Skill {
17    pub name: String,
18    pub description: String,
19    pub path: PathBuf,
20}
21
22/// Discovered prompt template.
23#[derive(Debug, Clone)]
24pub struct PromptTemplate {
25    pub name: String,
26    pub path: PathBuf,
27    pub content: String,
28}
29
30impl PromptTemplate {
31    /// Expand `{{variable}}` placeholders with the given values.
32    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/// Discovered soul document.
43#[derive(Debug, Clone)]
44pub struct SoulDoc {
45    pub path: PathBuf,
46    pub content: String,
47}
48
49/// Discover the nearest project soul document by walking up from cwd.
50pub 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
62/// Suggest where a new project soul should be created.
63///
64/// Prefers the nearest ancestor that looks like a project root. Falls back to `cwd/.imp/soul.md`.
65pub 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
85/// Discover the active soul document.
86///
87/// Precedence:
88/// 1. nearest project `.imp/soul.md` while walking up from cwd
89/// 2. global `<user_config_dir>/soul.md`
90pub 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
142/// Discover instruction documents by walking up from cwd.
143///
144/// Canonical imp-native files are `.imp/agents.md` at global and project scope.
145/// Legacy compatibility files (`AGENTS.md`, `CLAUDE.md`) are still read after the
146/// canonical file at each scope level.
147pub 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
167/// Discover skills from user and project directories.
168pub 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
212/// Discover prompt templates.
213pub 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
245/// Extract the first paragraph as a description from a markdown file.
246pub 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    // -- soul discovery --
265
266    #[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    // -- AGENTS.md discovery --
343
344    #[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        // Create AGENTS.md at the project root
365        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        // Global should appear before project
401        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    // -- Skills discovery --
510
511    #[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        // No SKILL.md — just a random file
625        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    // -- Prompt template discovery --
647
648    #[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    // -- Template expansion --
716
717    #[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        // "role" not provided
744
745        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    // -- extract_description --
778
779    #[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}