Skip to main content

imp_core/
resources.rs

1use std::collections::HashMap;
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
120/// Discover instruction documents by walking up from cwd.
121///
122/// Canonical imp-native files are `.imp/agents.md` at global and project scope.
123/// Legacy compatibility files (`AGENTS.md`, `CLAUDE.md`) are still read after the
124/// canonical file at each scope level.
125pub fn discover_agents_md(cwd: &Path, user_config_dir: &Path) -> Vec<AgentsMd> {
126    let mut results = Vec::new();
127
128    for path in global_agents_candidates(user_config_dir) {
129        if let Ok(content) = std::fs::read_to_string(&path) {
130            results.push(AgentsMd { path, content });
131        }
132    }
133
134    let mut dir = Some(cwd);
135    while let Some(d) = dir {
136        for path in project_agents_candidates(d) {
137            if let Ok(content) = std::fs::read_to_string(&path) {
138                results.push(AgentsMd { path, content });
139            }
140        }
141        dir = d.parent();
142    }
143
144    results
145}
146
147/// Discover skills from user and project directories.
148pub fn discover_skills(cwd: &Path, user_config_dir: &Path) -> Vec<Skill> {
149    let mut by_name = HashMap::new();
150    let mut dirs = vec![user_config_dir.join("skills")];
151
152    let mut ancestry = Vec::new();
153    let mut dir = Some(cwd);
154    while let Some(current) = dir {
155        ancestry.push(storage::project_skills_dir(current));
156        dir = current.parent();
157    }
158    ancestry.reverse();
159    dirs.extend(ancestry);
160
161    for dir in &dirs {
162        if let Ok(entries) = std::fs::read_dir(dir) {
163            for entry in entries.flatten() {
164                let skill_dir = entry.path();
165                let skill_file = skill_dir.join("SKILL.md");
166                if skill_file.exists() {
167                    if let Ok(content) = std::fs::read_to_string(&skill_file) {
168                        let name = skill_dir
169                            .file_name()
170                            .map(|n| n.to_string_lossy().to_string())
171                            .unwrap_or_default();
172                        let description = extract_description(&content);
173                        by_name.insert(
174                            name.clone(),
175                            Skill {
176                                name,
177                                description,
178                                path: skill_file,
179                            },
180                        );
181                    }
182                }
183            }
184        }
185    }
186
187    let mut skills: Vec<Skill> = by_name.into_values().collect();
188    skills.sort_by(|a, b| a.name.cmp(&b.name));
189    skills
190}
191
192/// Discover prompt templates.
193pub fn discover_prompts(cwd: &Path, user_config_dir: &Path) -> Result<Vec<PromptTemplate>> {
194    let mut prompts = Vec::new();
195
196    let dirs = [
197        user_config_dir.join("prompts"),
198        storage::project_prompts_dir(cwd),
199    ];
200
201    for dir in &dirs {
202        if let Ok(entries) = std::fs::read_dir(dir) {
203            for entry in entries.flatten() {
204                let path = entry.path();
205                if path.extension().is_some_and(|e| e == "md") {
206                    if let Ok(content) = std::fs::read_to_string(&path) {
207                        let name = path
208                            .file_stem()
209                            .map(|n| n.to_string_lossy().to_string())
210                            .unwrap_or_default();
211                        prompts.push(PromptTemplate {
212                            name,
213                            path,
214                            content,
215                        });
216                    }
217                }
218            }
219        }
220    }
221
222    Ok(prompts)
223}
224
225/// Extract the first paragraph as a description from a markdown file.
226pub fn extract_description(content: &str) -> String {
227    content
228        .lines()
229        .skip_while(|l| l.starts_with('#') || l.trim().is_empty())
230        .take_while(|l| !l.trim().is_empty())
231        .collect::<Vec<_>>()
232        .join(" ")
233        .chars()
234        .take(200)
235        .collect()
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use std::fs;
242    use tempfile::TempDir;
243
244    // -- soul discovery --
245
246    #[test]
247    fn resource_discover_soul_uses_global_fallback() {
248        let dir = TempDir::new().unwrap();
249        let user_dir = dir.path().join("config");
250        let cwd = dir.path().join("project");
251        fs::create_dir_all(&user_dir).unwrap();
252        fs::create_dir_all(&cwd).unwrap();
253        fs::write(user_dir.join("soul.md"), "# Soul\n\nglobal soul").unwrap();
254
255        let soul = discover_soul(&cwd, &user_dir).expect("global soul should load");
256        assert!(soul.content.contains("global soul"));
257        assert_eq!(soul.path, user_dir.join("soul.md"));
258    }
259
260    #[test]
261    fn resource_discover_soul_prefers_nearest_project_override() {
262        let dir = TempDir::new().unwrap();
263        let user_dir = dir.path().join("config");
264        let project = dir.path().join("project");
265        let nested = project.join("src").join("deep");
266        fs::create_dir_all(&user_dir).unwrap();
267        fs::create_dir_all(project.join(".imp")).unwrap();
268        fs::create_dir_all(&nested).unwrap();
269        fs::write(user_dir.join("soul.md"), "# Soul\n\nglobal soul").unwrap();
270        fs::write(
271            project.join(".imp").join("soul.md"),
272            "# Soul\n\nproject soul",
273        )
274        .unwrap();
275
276        let soul = discover_soul(&nested, &user_dir).expect("project soul should load");
277        assert!(soul.content.contains("project soul"));
278        assert_eq!(soul.path, project.join(".imp").join("soul.md"));
279    }
280
281    #[test]
282    fn resource_discover_project_soul_walks_up_from_cwd() {
283        let dir = TempDir::new().unwrap();
284        let project = dir.path().join("project");
285        let nested = project.join("src").join("deep");
286        fs::create_dir_all(project.join(".imp")).unwrap();
287        fs::create_dir_all(&nested).unwrap();
288        fs::write(
289            project.join(".imp").join("soul.md"),
290            "# Soul\n\nproject soul",
291        )
292        .unwrap();
293
294        let soul = discover_project_soul(&nested).expect("project soul should load");
295        assert!(soul.content.contains("project soul"));
296        assert_eq!(soul.path, project.join(".imp").join("soul.md"));
297    }
298
299    #[test]
300    fn resource_suggested_project_soul_path_prefers_nearest_projectish_ancestor() {
301        let dir = TempDir::new().unwrap();
302        let project = dir.path().join("project");
303        let nested = project.join("src").join("deep");
304        fs::create_dir_all(&nested).unwrap();
305        fs::write(project.join("Cargo.toml"), "[package]\nname = \"demo\"\n").unwrap();
306
307        let path = suggested_project_soul_path(&nested);
308        assert_eq!(path, project.join(".imp").join("soul.md"));
309    }
310
311    #[test]
312    fn resource_discover_soul_empty_when_absent() {
313        let dir = TempDir::new().unwrap();
314        let user_dir = dir.path().join("config");
315        let cwd = dir.path().join("project");
316        fs::create_dir_all(&user_dir).unwrap();
317        fs::create_dir_all(&cwd).unwrap();
318
319        assert!(discover_soul(&cwd, &user_dir).is_none());
320    }
321
322    // -- AGENTS.md discovery --
323
324    #[test]
325    fn resource_discover_agents_md_from_user_config() {
326        let dir = TempDir::new().unwrap();
327        let user_dir = dir.path().join("config");
328        fs::create_dir_all(&user_dir).unwrap();
329        fs::write(user_dir.join("AGENTS.md"), "# Global rules").unwrap();
330
331        let cwd = dir.path().join("project");
332        fs::create_dir_all(&cwd).unwrap();
333
334        let results = discover_agents_md(&cwd, &user_dir);
335        assert!(results.iter().any(|a| a.content.contains("Global rules")));
336    }
337
338    #[test]
339    fn resource_discover_agents_md_walks_up_from_cwd() {
340        let dir = TempDir::new().unwrap();
341        let user_dir = dir.path().join("config");
342        fs::create_dir_all(&user_dir).unwrap();
343
344        // Create AGENTS.md at the project root
345        let project = dir.path().join("project");
346        let subdir = project.join("src").join("deep");
347        fs::create_dir_all(&subdir).unwrap();
348        fs::write(project.join("AGENTS.md"), "# Project rules").unwrap();
349
350        let results = discover_agents_md(&subdir, &user_dir);
351        assert!(results.iter().any(|a| a.content.contains("Project rules")));
352    }
353
354    #[test]
355    fn resource_discover_agents_md_finds_claude_md() {
356        let dir = TempDir::new().unwrap();
357        let user_dir = dir.path().join("config");
358        fs::create_dir_all(&user_dir).unwrap();
359        fs::write(user_dir.join("CLAUDE.md"), "# Claude config").unwrap();
360
361        let cwd = dir.path().join("project");
362        fs::create_dir_all(&cwd).unwrap();
363
364        let results = discover_agents_md(&cwd, &user_dir);
365        assert!(results.iter().any(|a| a.content.contains("Claude config")));
366    }
367
368    #[test]
369    fn resource_discover_agents_md_global_first() {
370        let dir = TempDir::new().unwrap();
371        let user_dir = dir.path().join("config");
372        let project = dir.path().join("project");
373        fs::create_dir_all(&user_dir).unwrap();
374        fs::create_dir_all(&project).unwrap();
375
376        fs::write(user_dir.join("AGENTS.md"), "global").unwrap();
377        fs::write(project.join("AGENTS.md"), "project").unwrap();
378
379        let results = discover_agents_md(&project, &user_dir);
380        // Global should appear before project
381        let global_idx = results.iter().position(|a| a.content == "global").unwrap();
382        let project_idx = results.iter().position(|a| a.content == "project").unwrap();
383        assert!(global_idx < project_idx);
384    }
385
386    #[test]
387    fn resource_discover_agents_md_reads_global_imp_agents_file() {
388        let dir = TempDir::new().unwrap();
389        let user_dir = dir.path().join("config");
390        fs::create_dir_all(&user_dir).unwrap();
391        fs::write(user_dir.join("agents.md"), "global-imp").unwrap();
392
393        let cwd = dir.path().join("project");
394        fs::create_dir_all(&cwd).unwrap();
395
396        let results = discover_agents_md(&cwd, &user_dir);
397        assert!(results.iter().any(|a| a.content == "global-imp"));
398    }
399
400    #[test]
401    fn resource_discover_agents_md_prefers_project_imp_agents_file() {
402        let dir = TempDir::new().unwrap();
403        let user_dir = dir.path().join("config");
404        let project = dir.path().join("project");
405        fs::create_dir_all(&user_dir).unwrap();
406        fs::create_dir_all(project.join(".imp")).unwrap();
407        fs::write(project.join(".imp").join("agents.md"), "project-imp").unwrap();
408        fs::write(project.join("AGENTS.md"), "project-legacy").unwrap();
409
410        let results = discover_agents_md(&project, &user_dir);
411        let canonical_idx = results
412            .iter()
413            .position(|a| a.content == "project-imp")
414            .unwrap();
415        let legacy_idx = results
416            .iter()
417            .position(|a| a.content == "project-legacy")
418            .unwrap();
419        assert!(canonical_idx < legacy_idx);
420    }
421
422    #[test]
423    fn resource_discover_agents_md_empty_when_no_files() {
424        let dir = TempDir::new().unwrap();
425        let user_dir = dir.path().join("config");
426        let cwd = dir.path().join("project");
427        fs::create_dir_all(&user_dir).unwrap();
428        fs::create_dir_all(&cwd).unwrap();
429
430        let results = discover_agents_md(&cwd, &user_dir);
431        assert!(results.is_empty());
432    }
433
434    // -- Skills discovery --
435
436    #[test]
437    fn resource_discover_skills_from_user_dir() {
438        let dir = TempDir::new().unwrap();
439        let user_dir = dir.path().join("config");
440        let skills_dir = user_dir.join("skills").join("my-skill");
441        fs::create_dir_all(&skills_dir).unwrap();
442        fs::write(
443            skills_dir.join("SKILL.md"),
444            "# My Skill\n\nDoes useful things for you.\n",
445        )
446        .unwrap();
447
448        let cwd = dir.path().join("project");
449        fs::create_dir_all(&cwd).unwrap();
450
451        let skills = discover_skills(&cwd, &user_dir);
452        assert_eq!(skills.len(), 1);
453        assert_eq!(skills[0].name, "my-skill");
454        assert!(skills[0].description.contains("useful things"));
455    }
456
457    #[test]
458    fn resource_discover_skills_from_project_dir() {
459        let dir = TempDir::new().unwrap();
460        let user_dir = dir.path().join("config");
461        fs::create_dir_all(&user_dir).unwrap();
462
463        let cwd = dir.path().join("project");
464        let skills_dir = cwd.join(".imp").join("skills").join("project-skill");
465        fs::create_dir_all(&skills_dir).unwrap();
466        fs::write(
467            skills_dir.join("SKILL.md"),
468            "# Project Skill\n\nProject-specific automation.\n",
469        )
470        .unwrap();
471
472        let skills = discover_skills(&cwd, &user_dir);
473        assert_eq!(skills.len(), 1);
474        assert_eq!(skills[0].name, "project-skill");
475    }
476
477    #[test]
478    fn resource_discover_skills_from_both_dirs() {
479        let dir = TempDir::new().unwrap();
480        let user_dir = dir.path().join("config");
481        let user_skills = user_dir.join("skills").join("global-skill");
482        fs::create_dir_all(&user_skills).unwrap();
483        fs::write(user_skills.join("SKILL.md"), "# Global\n\nGlobal skill.\n").unwrap();
484
485        let cwd = dir.path().join("project");
486        let project_skills = cwd.join(".imp").join("skills").join("local-skill");
487        fs::create_dir_all(&project_skills).unwrap();
488        fs::write(project_skills.join("SKILL.md"), "# Local\n\nLocal skill.\n").unwrap();
489
490        let skills = discover_skills(&cwd, &user_dir);
491        assert_eq!(skills.len(), 2);
492        let names: Vec<&str> = skills.iter().map(|s| s.name.as_str()).collect();
493        assert!(names.contains(&"global-skill"));
494        assert!(names.contains(&"local-skill"));
495    }
496
497    #[test]
498    fn resource_discover_skills_walks_up_from_cwd() {
499        let dir = TempDir::new().unwrap();
500        let user_dir = dir.path().join("config");
501        fs::create_dir_all(&user_dir).unwrap();
502
503        let project = dir.path().join("project");
504        let nested = project.join("src").join("deep");
505        let skills_dir = project.join(".imp").join("skills").join("project-skill");
506        fs::create_dir_all(&skills_dir).unwrap();
507        fs::create_dir_all(&nested).unwrap();
508        fs::write(
509            skills_dir.join("SKILL.md"),
510            "# Project Skill\n\nProject-specific automation.\n",
511        )
512        .unwrap();
513
514        let skills = discover_skills(&nested, &user_dir);
515        assert_eq!(skills.len(), 1);
516        assert_eq!(skills[0].name, "project-skill");
517    }
518
519    #[test]
520    fn resource_discover_skills_project_overrides_user_by_name() {
521        let dir = TempDir::new().unwrap();
522        let user_dir = dir.path().join("config");
523        let user_skill = user_dir.join("skills").join("mana");
524        fs::create_dir_all(&user_skill).unwrap();
525        fs::write(user_skill.join("SKILL.md"), "# Mana\n\nUser version.\n").unwrap();
526
527        let project = dir.path().join("project");
528        let project_skill = project.join(".imp").join("skills").join("mana");
529        fs::create_dir_all(&project_skill).unwrap();
530        fs::write(
531            project_skill.join("SKILL.md"),
532            "# Mana\n\nProject version.\n",
533        )
534        .unwrap();
535
536        let skills = discover_skills(&project, &user_dir);
537        assert_eq!(skills.len(), 1);
538        assert_eq!(skills[0].name, "mana");
539        assert!(skills[0].description.contains("Project version"));
540        assert_eq!(skills[0].path, project_skill.join("SKILL.md"));
541    }
542
543    #[test]
544    fn resource_discover_skills_skips_dirs_without_skill_md() {
545        let dir = TempDir::new().unwrap();
546        let user_dir = dir.path().join("config");
547        let skills_dir = user_dir.join("skills").join("incomplete-skill");
548        fs::create_dir_all(&skills_dir).unwrap();
549        // No SKILL.md — just a random file
550        fs::write(skills_dir.join("README.md"), "not a skill").unwrap();
551
552        let cwd = dir.path().join("project");
553        fs::create_dir_all(&cwd).unwrap();
554
555        let skills = discover_skills(&cwd, &user_dir);
556        assert!(skills.is_empty());
557    }
558
559    #[test]
560    fn resource_discover_skills_empty_when_no_dirs() {
561        let dir = TempDir::new().unwrap();
562        let user_dir = dir.path().join("config");
563        let cwd = dir.path().join("project");
564        fs::create_dir_all(&user_dir).unwrap();
565        fs::create_dir_all(&cwd).unwrap();
566
567        let skills = discover_skills(&cwd, &user_dir);
568        assert!(skills.is_empty());
569    }
570
571    // -- Prompt template discovery --
572
573    #[test]
574    fn resource_discover_prompts_from_user_dir() {
575        let dir = TempDir::new().unwrap();
576        let user_dir = dir.path().join("config");
577        let prompts_dir = user_dir.join("prompts");
578        fs::create_dir_all(&prompts_dir).unwrap();
579        fs::write(prompts_dir.join("review.md"), "Review this code: {{code}}").unwrap();
580
581        let cwd = dir.path().join("project");
582        fs::create_dir_all(&cwd).unwrap();
583
584        let prompts = discover_prompts(&cwd, &user_dir).unwrap();
585        assert_eq!(prompts.len(), 1);
586        assert_eq!(prompts[0].name, "review");
587        assert!(prompts[0].content.contains("{{code}}"));
588    }
589
590    #[test]
591    fn resource_discover_prompts_from_project_dir() {
592        let dir = TempDir::new().unwrap();
593        let user_dir = dir.path().join("config");
594        fs::create_dir_all(&user_dir).unwrap();
595
596        let cwd = dir.path().join("project");
597        let prompts_dir = cwd.join(".imp").join("prompts");
598        fs::create_dir_all(&prompts_dir).unwrap();
599        fs::write(
600            prompts_dir.join("deploy.md"),
601            "Deploy {{service}} to {{env}}",
602        )
603        .unwrap();
604
605        let prompts = discover_prompts(&cwd, &user_dir).unwrap();
606        assert_eq!(prompts.len(), 1);
607        assert_eq!(prompts[0].name, "deploy");
608    }
609
610    #[test]
611    fn resource_discover_prompts_ignores_non_md_files() {
612        let dir = TempDir::new().unwrap();
613        let user_dir = dir.path().join("config");
614        let prompts_dir = user_dir.join("prompts");
615        fs::create_dir_all(&prompts_dir).unwrap();
616        fs::write(prompts_dir.join("valid.md"), "prompt content").unwrap();
617        fs::write(prompts_dir.join("ignored.txt"), "not a prompt").unwrap();
618        fs::write(prompts_dir.join("also_ignored.toml"), "nope").unwrap();
619
620        let cwd = dir.path().join("project");
621        fs::create_dir_all(&cwd).unwrap();
622
623        let prompts = discover_prompts(&cwd, &user_dir).unwrap();
624        assert_eq!(prompts.len(), 1);
625        assert_eq!(prompts[0].name, "valid");
626    }
627
628    #[test]
629    fn resource_discover_prompts_empty_when_no_dirs() {
630        let dir = TempDir::new().unwrap();
631        let user_dir = dir.path().join("config");
632        let cwd = dir.path().join("project");
633        fs::create_dir_all(&user_dir).unwrap();
634        fs::create_dir_all(&cwd).unwrap();
635
636        let prompts = discover_prompts(&cwd, &user_dir).unwrap();
637        assert!(prompts.is_empty());
638    }
639
640    // -- Template expansion --
641
642    #[test]
643    fn resource_prompt_template_expand_variables() {
644        let template = PromptTemplate {
645            name: "test".into(),
646            path: PathBuf::from("test.md"),
647            content: "Hello {{name}}, welcome to {{project}}!".into(),
648        };
649
650        let mut vars = HashMap::new();
651        vars.insert("name".into(), "Alice".into());
652        vars.insert("project".into(), "imp".into());
653
654        let result = template.expand(&vars);
655        assert_eq!(result, "Hello Alice, welcome to imp!");
656    }
657
658    #[test]
659    fn resource_prompt_template_expand_missing_variable_left_as_is() {
660        let template = PromptTemplate {
661            name: "test".into(),
662            path: PathBuf::from("test.md"),
663            content: "Hello {{name}}, your role is {{role}}.".into(),
664        };
665
666        let mut vars = HashMap::new();
667        vars.insert("name".into(), "Bob".into());
668        // "role" not provided
669
670        let result = template.expand(&vars);
671        assert_eq!(result, "Hello Bob, your role is {{role}}.");
672    }
673
674    #[test]
675    fn resource_prompt_template_expand_empty_vars() {
676        let template = PromptTemplate {
677            name: "test".into(),
678            path: PathBuf::from("test.md"),
679            content: "No variables here.".into(),
680        };
681
682        let vars = HashMap::new();
683        let result = template.expand(&vars);
684        assert_eq!(result, "No variables here.");
685    }
686
687    #[test]
688    fn resource_prompt_template_expand_repeated_variable() {
689        let template = PromptTemplate {
690            name: "test".into(),
691            path: PathBuf::from("test.md"),
692            content: "{{x}} and {{x}} again".into(),
693        };
694
695        let mut vars = HashMap::new();
696        vars.insert("x".into(), "hello".into());
697
698        let result = template.expand(&vars);
699        assert_eq!(result, "hello and hello again");
700    }
701
702    // -- extract_description --
703
704    #[test]
705    fn resource_extract_description_skips_headings() {
706        let content = "# Title\n\nThis is the description.\nMore text here.\n\n## Section";
707        let desc = extract_description(content);
708        assert_eq!(desc, "This is the description. More text here.");
709    }
710
711    #[test]
712    fn resource_extract_description_empty_content() {
713        assert_eq!(extract_description(""), "");
714    }
715
716    #[test]
717    fn resource_extract_description_truncates_at_200_chars() {
718        let long_line = "A".repeat(250);
719        let content = format!("# Title\n\n{}", long_line);
720        let desc = extract_description(&content);
721        assert_eq!(desc.len(), 200);
722    }
723}