1use std::collections::HashMap;
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
120pub 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
147pub 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
192pub 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
225pub 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 #[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 #[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 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 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 #[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 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 #[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 #[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 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 #[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}