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/// Return markdown content without leading YAML frontmatter.
259pub 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
270/// Render a skill body for explicit slash-command invocation.
271pub 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    // -- soul discovery --
292
293    #[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    // -- AGENTS.md discovery --
370
371    #[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        // Create AGENTS.md at the project root
392        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        // Global should appear before project
428        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    // -- Skills discovery --
537
538    #[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        // No SKILL.md — just a random file
652        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    // -- Prompt template discovery --
674
675    #[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    // -- Template expansion --
743
744    #[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        // "role" not provided
771
772        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    // -- extract_description --
805
806    #[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}