1use std::path::{Path, PathBuf};
2
3#[derive(Debug, Clone)]
5pub struct DetectedSource {
6 pub agent: AgentSource,
7 pub skills: Vec<DetectedSkill>,
8 pub agents_md: Vec<DetectedAgentsMd>,
9 pub typescript_extensions: Vec<DetectedTypeScriptExtension>,
10}
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum AgentSource {
15 Pi,
16 ClaudeCode,
17 Codex,
18}
19
20impl AgentSource {
21 pub fn label(&self) -> &'static str {
22 match self {
23 Self::Pi => "pi",
24 Self::ClaudeCode => "Claude Code",
25 Self::Codex => "Codex",
26 }
27 }
28
29 pub fn import_namespace(&self) -> &'static str {
30 match self {
31 Self::Pi => "pi",
32 Self::ClaudeCode => "claude-code",
33 Self::Codex => "codex",
34 }
35 }
36}
37
38#[derive(Debug, Clone)]
40pub struct DetectedTypeScriptExtension {
41 pub name: String,
42 pub source_path: PathBuf,
43 pub entrypoints: Vec<PathBuf>,
44 pub package_name: Option<String>,
45 pub shape: TypeScriptExtensionShape,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum TypeScriptExtensionShape {
50 SingleFile,
51 PackageJson,
52 FallbackIndex,
53}
54
55#[derive(Debug, Clone)]
57pub struct DetectedSkill {
58 pub name: String,
59 pub description: String,
60 pub source_path: PathBuf,
61}
62
63#[derive(Debug, Clone)]
65pub struct DetectedAgentsMd {
66 pub path: PathBuf,
67 pub kind: AgentsMdKind,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum AgentsMdKind {
72 AgentsMd,
73 ClaudeMd,
74}
75
76impl AgentsMdKind {
77 pub fn label(&self) -> &'static str {
78 match self {
79 Self::AgentsMd => "AGENTS.md",
80 Self::ClaudeMd => "CLAUDE.md",
81 }
82 }
83}
84
85pub fn detect_sources(home: &Path) -> Vec<DetectedSource> {
87 let mut sources = Vec::new();
88
89 if let Some(pi) = detect_pi(home) {
90 sources.push(pi);
91 }
92 if let Some(claude) = detect_claude_code(home) {
93 sources.push(claude);
94 }
95 if let Some(codex) = detect_codex(home) {
96 sources.push(codex);
97 }
98
99 sources
100}
101
102fn detect_pi(home: &Path) -> Option<DetectedSource> {
103 let pi_dir = home.join(".pi").join("agent");
104 if !pi_dir.exists() {
105 return None;
106 }
107
108 let skills = discover_skills_in_dir(&pi_dir.join("skills"));
109 let typescript_extensions = discover_pi_typescript_extensions(&pi_dir.join("extensions"));
110
111 let mut agents_md = Vec::new();
112 let agents_path = pi_dir.join("AGENTS.md");
113 if agents_path.exists() {
114 agents_md.push(DetectedAgentsMd {
115 path: agents_path,
116 kind: AgentsMdKind::AgentsMd,
117 });
118 }
119
120 if skills.is_empty() && agents_md.is_empty() && typescript_extensions.is_empty() {
121 return None;
122 }
123
124 Some(DetectedSource {
125 agent: AgentSource::Pi,
126 skills,
127 agents_md,
128 typescript_extensions,
129 })
130}
131
132fn detect_claude_code(home: &Path) -> Option<DetectedSource> {
133 let claude_dir = home.join(".claude");
134 if !claude_dir.exists() {
135 return None;
136 }
137
138 let mut agents_md = Vec::new();
139
140 let claude_md = claude_dir.join("CLAUDE.md");
142 if claude_md.exists() {
143 agents_md.push(DetectedAgentsMd {
144 path: claude_md,
145 kind: AgentsMdKind::ClaudeMd,
146 });
147 }
148
149 if agents_md.is_empty() {
150 return None;
151 }
152
153 Some(DetectedSource {
154 agent: AgentSource::ClaudeCode,
155 skills: Vec::new(),
156 agents_md,
157 typescript_extensions: Vec::new(),
158 })
159}
160
161fn detect_codex(home: &Path) -> Option<DetectedSource> {
162 let codex_dir = home.join(".codex");
165 if !codex_dir.exists() {
166 return None;
167 }
168
169 let mut agents_md = Vec::new();
170
171 let instructions = codex_dir.join("instructions.md");
172 if instructions.exists() {
173 agents_md.push(DetectedAgentsMd {
174 path: instructions,
175 kind: AgentsMdKind::AgentsMd,
176 });
177 }
178
179 if agents_md.is_empty() {
180 return None;
181 }
182
183 Some(DetectedSource {
184 agent: AgentSource::Codex,
185 skills: Vec::new(),
186 agents_md,
187 typescript_extensions: Vec::new(),
188 })
189}
190
191fn discover_pi_typescript_extensions(dir: &Path) -> Vec<DetectedTypeScriptExtension> {
192 let mut extensions = Vec::new();
193
194 let entries = match std::fs::read_dir(dir) {
195 Ok(entries) => entries,
196 Err(_) => return extensions,
197 };
198
199 for entry in entries.flatten() {
200 let path = entry.path();
201 if path.is_file() {
202 if path.extension().and_then(|ext| ext.to_str()) == Some("ts") {
203 if let Some(name) = file_stem_string(&path) {
204 extensions.push(DetectedTypeScriptExtension {
205 name,
206 source_path: path.clone(),
207 entrypoints: vec![path],
208 package_name: None,
209 shape: TypeScriptExtensionShape::SingleFile,
210 });
211 }
212 }
213 continue;
214 }
215
216 if !path.is_dir() {
217 continue;
218 }
219
220 if let Some(extension) = detect_pi_extension_dir(&path) {
221 extensions.push(extension);
222 }
223 }
224
225 extensions.sort_by(|a, b| a.name.cmp(&b.name));
226 extensions
227}
228
229fn detect_pi_extension_dir(dir: &Path) -> Option<DetectedTypeScriptExtension> {
230 let name = dir.file_name()?.to_string_lossy().to_string();
231 let package_json = dir.join("package.json");
232
233 if package_json.exists() {
234 if let Ok(content) = std::fs::read_to_string(&package_json) {
235 if let Ok(package) = serde_json::from_str::<serde_json::Value>(&content) {
236 let entrypoints = package
237 .get("pi")
238 .and_then(|pi| pi.get("extensions"))
239 .and_then(|extensions| extensions.as_array())
240 .map(|extensions| {
241 extensions
242 .iter()
243 .filter_map(|entry| entry.as_str())
244 .map(|entry| dir.join(entry))
245 .collect::<Vec<_>>()
246 })
247 .unwrap_or_default();
248
249 if !entrypoints.is_empty() {
250 return Some(DetectedTypeScriptExtension {
251 name,
252 source_path: dir.to_path_buf(),
253 entrypoints,
254 package_name: package
255 .get("name")
256 .and_then(|value| value.as_str())
257 .map(ToOwned::to_owned),
258 shape: TypeScriptExtensionShape::PackageJson,
259 });
260 }
261 }
262 }
263 }
264
265 let index = dir.join("index.ts");
266 index.exists().then(|| DetectedTypeScriptExtension {
267 name,
268 source_path: dir.to_path_buf(),
269 entrypoints: vec![index],
270 package_name: None,
271 shape: TypeScriptExtensionShape::FallbackIndex,
272 })
273}
274
275fn file_stem_string(path: &Path) -> Option<String> {
276 path.file_stem()
277 .map(|stem| stem.to_string_lossy().to_string())
278 .filter(|stem| !stem.is_empty())
279}
280
281fn discover_skills_in_dir(dir: &Path) -> Vec<DetectedSkill> {
282 let mut skills = Vec::new();
283
284 let entries = match std::fs::read_dir(dir) {
285 Ok(entries) => entries,
286 Err(_) => return skills,
287 };
288
289 for entry in entries.flatten() {
290 let skill_dir = entry.path();
291 let skill_file = skill_dir.join("SKILL.md");
292 if !skill_file.exists() {
293 continue;
294 }
295
296 let content = match std::fs::read_to_string(&skill_file) {
297 Ok(c) => c,
298 Err(_) => continue,
299 };
300
301 let name = skill_dir
302 .file_name()
303 .map(|n| n.to_string_lossy().to_string())
304 .unwrap_or_default();
305
306 let description = extract_skill_description(&content);
307
308 skills.push(DetectedSkill {
309 name,
310 description,
311 source_path: skill_file,
312 });
313 }
314
315 skills.sort_by(|a, b| a.name.cmp(&b.name));
316 skills
317}
318
319fn extract_skill_description(content: &str) -> String {
320 let lines: Vec<&str> = content.lines().collect();
323 if lines.first().copied() != Some("---") {
324 return crate::resources::extract_description(content);
325 }
326
327 let end = lines
328 .iter()
329 .enumerate()
330 .skip(1)
331 .find_map(|(i, l)| (*l == "---").then_some(i));
332
333 let Some(end) = end else {
334 return String::new();
335 };
336
337 let mut description = String::new();
339 let mut in_description = false;
340
341 for line in &lines[1..end] {
342 if let Some(rest) = line.strip_prefix("description:") {
343 let value = rest.trim();
345 if value == ">" || value == "|" {
346 in_description = true;
348 continue;
349 }
350 let value = value.trim_matches('\'').trim_matches('"');
352 return value.to_string();
353 } else if in_description {
354 let trimmed = line.trim();
355 if trimmed.is_empty() {
356 break;
358 }
359 if !line.starts_with(' ') && !line.starts_with('\t') {
360 break;
362 }
363 if !description.is_empty() {
364 description.push(' ');
365 }
366 description.push_str(trimmed);
367 }
368 }
369
370 description
371}
372
373#[derive(Debug)]
375pub struct ImportResult {
376 pub copied: Vec<String>,
377 pub skipped: Vec<(String, SkipReason)>,
378}
379
380#[derive(Debug)]
381pub enum SkipReason {
382 AlreadyExists,
383 CopyFailed(String),
384}
385
386pub fn import_skills(
388 skills: &[DetectedSkill],
389 imp_skills_dir: &Path,
390) -> std::io::Result<ImportResult> {
391 std::fs::create_dir_all(imp_skills_dir)?;
392
393 let mut result = ImportResult {
394 copied: Vec::new(),
395 skipped: Vec::new(),
396 };
397
398 for skill in skills {
399 let dest_dir = imp_skills_dir.join(&skill.name);
400
401 if dest_dir.exists() {
402 result
403 .skipped
404 .push((skill.name.clone(), SkipReason::AlreadyExists));
405 continue;
406 }
407
408 let source_dir = skill.source_path.parent().unwrap_or(Path::new("."));
410 match copy_dir_recursive(source_dir, &dest_dir) {
411 Ok(()) => result.copied.push(skill.name.clone()),
412 Err(e) => result
413 .skipped
414 .push((skill.name.clone(), SkipReason::CopyFailed(e.to_string()))),
415 }
416 }
417
418 Ok(result)
419}
420
421pub fn import_typescript_extensions(
423 extensions: &[DetectedTypeScriptExtension],
424 imp_extensions_dir: &Path,
425 namespace: &str,
426) -> std::io::Result<ImportResult> {
427 let namespaced_dir = imp_extensions_dir.join(namespace);
428 std::fs::create_dir_all(&namespaced_dir)?;
429
430 let mut result = ImportResult {
431 copied: Vec::new(),
432 skipped: Vec::new(),
433 };
434
435 for extension in extensions {
436 let dest_dir = namespaced_dir.join(&extension.name);
437 if dest_dir.exists() {
438 result
439 .skipped
440 .push((extension.name.clone(), SkipReason::AlreadyExists));
441 continue;
442 }
443
444 let copy_result = if extension.source_path.is_dir() {
445 copy_dir_recursive(&extension.source_path, &dest_dir)
446 } else {
447 std::fs::create_dir_all(&dest_dir).and_then(|()| {
448 std::fs::copy(&extension.source_path, dest_dir.join("index.ts")).map(|_| ())
449 })
450 };
451
452 match copy_result {
453 Ok(()) => result.copied.push(extension.name.clone()),
454 Err(e) => result.skipped.push((
455 extension.name.clone(),
456 SkipReason::CopyFailed(e.to_string()),
457 )),
458 }
459 }
460
461 Ok(result)
462}
463
464pub fn import_agents_md(
468 source: &DetectedAgentsMd,
469 imp_config_dir: &Path,
470) -> std::io::Result<Option<PathBuf>> {
471 let dest = imp_config_dir.join("AGENTS.md");
472 if dest.exists() {
473 return Ok(None);
474 }
475
476 std::fs::create_dir_all(imp_config_dir)?;
477 std::fs::copy(&source.path, &dest)?;
478 Ok(Some(dest))
479}
480
481fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
482 std::fs::create_dir_all(dst)?;
483
484 for entry in std::fs::read_dir(src)? {
485 let entry = entry?;
486 let entry_path = entry.path();
487 let dest_path = dst.join(entry.file_name());
488
489 if entry_path.is_dir() {
490 copy_dir_recursive(&entry_path, &dest_path)?;
491 } else {
492 std::fs::copy(&entry_path, &dest_path)?;
493 }
494 }
495
496 Ok(())
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502 use tempfile::TempDir;
503
504 fn write_skill(dir: &Path, name: &str, description: &str) {
505 let skill_dir = dir.join(name);
506 std::fs::create_dir_all(&skill_dir).unwrap();
507 std::fs::write(
508 skill_dir.join("SKILL.md"),
509 format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
510 )
511 .unwrap();
512 }
513
514 fn write_pi_package(dir: &Path, name: &str, package_name: &str) {
515 let package_dir = dir.join(name);
516 std::fs::create_dir_all(&package_dir).unwrap();
517 std::fs::write(
518 package_dir.join("index.ts"),
519 "export default function(pi) {}\n",
520 )
521 .unwrap();
522 std::fs::write(
523 package_dir.join("package.json"),
524 format!(
525 r#"{{"name":"{package_name}","type":"module","pi":{{"extensions":["./index.ts"]}}}}"#
526 ),
527 )
528 .unwrap();
529 }
530
531 fn write_pi_fallback_package(dir: &Path, name: &str) {
532 let package_dir = dir.join(name);
533 std::fs::create_dir_all(&package_dir).unwrap();
534 std::fs::write(
535 package_dir.join("index.ts"),
536 "export default function(pi) {}\n",
537 )
538 .unwrap();
539 }
540
541 #[test]
542 fn detect_pi_skills() {
543 let home = TempDir::new().unwrap();
544 let skills_dir = home.path().join(".pi").join("agent").join("skills");
545 std::fs::create_dir_all(&skills_dir).unwrap();
546 write_skill(&skills_dir, "rust", "Rust conventions");
547 write_skill(&skills_dir, "testing", "Write tests");
548
549 let sources = detect_sources(home.path());
550 assert_eq!(sources.len(), 1);
551 assert_eq!(sources[0].agent, AgentSource::Pi);
552 assert_eq!(sources[0].skills.len(), 2);
553
554 let names: Vec<&str> = sources[0].skills.iter().map(|s| s.name.as_str()).collect();
555 assert!(names.contains(&"rust"));
556 assert!(names.contains(&"testing"));
557 }
558
559 #[test]
560 fn detect_pi_typescript_extensions() {
561 let home = TempDir::new().unwrap();
562 let extensions_dir = home.path().join(".pi").join("agent").join("extensions");
563 std::fs::create_dir_all(&extensions_dir).unwrap();
564 std::fs::write(
565 extensions_dir.join("ask.ts"),
566 "export default function(pi) {}\n",
567 )
568 .unwrap();
569 write_pi_package(
570 &extensions_dir,
571 "color-palette",
572 "pi-extension-color-palette",
573 );
574 write_pi_fallback_package(&extensions_dir, "mana");
575
576 let sources = detect_sources(home.path());
577 assert_eq!(sources.len(), 1);
578 let extensions = &sources[0].typescript_extensions;
579 assert_eq!(extensions.len(), 3);
580
581 let ask = extensions.iter().find(|ext| ext.name == "ask").unwrap();
582 assert_eq!(ask.shape, TypeScriptExtensionShape::SingleFile);
583 assert_eq!(ask.entrypoints.len(), 1);
584
585 let color_palette = extensions
586 .iter()
587 .find(|ext| ext.name == "color-palette")
588 .unwrap();
589 assert_eq!(color_palette.shape, TypeScriptExtensionShape::PackageJson);
590 assert_eq!(
591 color_palette.package_name.as_deref(),
592 Some("pi-extension-color-palette")
593 );
594
595 let mana = extensions.iter().find(|ext| ext.name == "mana").unwrap();
596 assert_eq!(mana.shape, TypeScriptExtensionShape::FallbackIndex);
597 }
598
599 #[test]
600 fn detect_pi_agents_md() {
601 let home = TempDir::new().unwrap();
602 let agent_dir = home.path().join(".pi").join("agent");
603 std::fs::create_dir_all(&agent_dir).unwrap();
604 std::fs::write(agent_dir.join("AGENTS.md"), "# Global rules").unwrap();
605
606 let sources = detect_sources(home.path());
607 assert_eq!(sources.len(), 1);
608 assert_eq!(sources[0].agents_md.len(), 1);
609 assert_eq!(sources[0].agents_md[0].kind, AgentsMdKind::AgentsMd);
610 }
611
612 #[test]
613 fn detect_claude_code() {
614 let home = TempDir::new().unwrap();
615 let claude_dir = home.path().join(".claude");
616 std::fs::create_dir_all(&claude_dir).unwrap();
617 std::fs::write(claude_dir.join("CLAUDE.md"), "# Claude config").unwrap();
618
619 let sources = detect_sources(home.path());
620 assert_eq!(sources.len(), 1);
621 assert_eq!(sources[0].agent, AgentSource::ClaudeCode);
622 assert_eq!(sources[0].agents_md.len(), 1);
623 assert_eq!(sources[0].agents_md[0].kind, AgentsMdKind::ClaudeMd);
624 }
625
626 #[test]
627 fn detect_codex_instructions() {
628 let home = TempDir::new().unwrap();
629 let codex_dir = home.path().join(".codex");
630 std::fs::create_dir_all(&codex_dir).unwrap();
631 std::fs::write(codex_dir.join("instructions.md"), "# Codex rules").unwrap();
632
633 let sources = detect_sources(home.path());
634 assert_eq!(sources.len(), 1);
635 assert_eq!(sources[0].agent, AgentSource::Codex);
636 }
637
638 #[test]
639 fn detect_nothing_when_no_agents_installed() {
640 let home = TempDir::new().unwrap();
641 let sources = detect_sources(home.path());
642 assert!(sources.is_empty());
643 }
644
645 #[test]
646 fn detect_multiple_sources() {
647 let home = TempDir::new().unwrap();
648
649 let pi_skills = home.path().join(".pi").join("agent").join("skills");
651 std::fs::create_dir_all(&pi_skills).unwrap();
652 write_skill(&pi_skills, "rust", "Rust");
653
654 let claude_dir = home.path().join(".claude");
656 std::fs::create_dir_all(&claude_dir).unwrap();
657 std::fs::write(claude_dir.join("CLAUDE.md"), "config").unwrap();
658
659 let sources = detect_sources(home.path());
660 assert_eq!(sources.len(), 2);
661 }
662
663 #[test]
664 fn import_copies_skills() {
665 let home = TempDir::new().unwrap();
666 let source_dir = home.path().join("source");
667 std::fs::create_dir_all(&source_dir).unwrap();
668 write_skill(&source_dir, "rust", "Rust conventions");
669 write_skill(&source_dir, "testing", "Write tests");
670
671 let skills = discover_skills_in_dir(&source_dir);
672 let dest = home.path().join("imp_skills");
673
674 let result = import_skills(&skills, &dest).unwrap();
675 assert_eq!(result.copied.len(), 2);
676 assert!(result.skipped.is_empty());
677
678 assert!(dest.join("rust").join("SKILL.md").exists());
680 assert!(dest.join("testing").join("SKILL.md").exists());
681 }
682
683 #[test]
684 fn import_skips_existing() {
685 let home = TempDir::new().unwrap();
686 let source_dir = home.path().join("source");
687 std::fs::create_dir_all(&source_dir).unwrap();
688 write_skill(&source_dir, "rust", "Rust conventions");
689
690 let dest = home.path().join("imp_skills");
691 std::fs::create_dir_all(dest.join("rust")).unwrap();
693 std::fs::write(dest.join("rust").join("SKILL.md"), "existing").unwrap();
694
695 let skills = discover_skills_in_dir(&source_dir);
696 let result = import_skills(&skills, &dest).unwrap();
697
698 assert!(result.copied.is_empty());
699 assert_eq!(result.skipped.len(), 1);
700 assert!(matches!(result.skipped[0].1, SkipReason::AlreadyExists));
701
702 let content = std::fs::read_to_string(dest.join("rust").join("SKILL.md")).unwrap();
704 assert_eq!(content, "existing");
705 }
706
707 #[test]
708 fn import_typescript_extensions_copies_to_namespace() {
709 let home = TempDir::new().unwrap();
710 let source_dir = home.path().join("source");
711 std::fs::create_dir_all(&source_dir).unwrap();
712 std::fs::write(
713 source_dir.join("ask.ts"),
714 "export default function(pi) {}\n",
715 )
716 .unwrap();
717 write_pi_package(&source_dir, "color-palette", "pi-extension-color-palette");
718
719 let extensions = discover_pi_typescript_extensions(&source_dir);
720 let dest = home.path().join(".imp").join("extensions");
721 let result = import_typescript_extensions(&extensions, &dest, "pi").unwrap();
722
723 assert_eq!(result.copied.len(), 2);
724 assert!(result.skipped.is_empty());
725 assert!(dest.join("pi").join("ask").join("index.ts").exists());
726 assert!(dest
727 .join("pi")
728 .join("color-palette")
729 .join("package.json")
730 .exists());
731 }
732
733 #[test]
734 fn import_typescript_extensions_skips_existing() {
735 let home = TempDir::new().unwrap();
736 let source_dir = home.path().join("source");
737 std::fs::create_dir_all(&source_dir).unwrap();
738 std::fs::write(
739 source_dir.join("ask.ts"),
740 "export default function(pi) {}\n",
741 )
742 .unwrap();
743
744 let extensions = discover_pi_typescript_extensions(&source_dir);
745 let dest = home.path().join(".imp").join("extensions");
746 std::fs::create_dir_all(dest.join("pi").join("ask")).unwrap();
747
748 let result = import_typescript_extensions(&extensions, &dest, "pi").unwrap();
749 assert!(result.copied.is_empty());
750 assert_eq!(result.skipped.len(), 1);
751 assert!(matches!(result.skipped[0].1, SkipReason::AlreadyExists));
752 }
753
754 #[test]
755 fn import_agents_md_copies_file() {
756 let home = TempDir::new().unwrap();
757 let source = home.path().join("source.md");
758 std::fs::write(&source, "# Global rules").unwrap();
759
760 let detected = DetectedAgentsMd {
761 path: source,
762 kind: AgentsMdKind::AgentsMd,
763 };
764
765 let imp_config = home.path().join("config");
766 let result = import_agents_md(&detected, &imp_config).unwrap();
767 assert!(result.is_some());
768
769 let dest = imp_config.join("AGENTS.md");
770 assert!(dest.exists());
771 assert_eq!(std::fs::read_to_string(dest).unwrap(), "# Global rules");
772 }
773
774 #[test]
775 fn import_agents_md_skips_existing() {
776 let home = TempDir::new().unwrap();
777 let source = home.path().join("source.md");
778 std::fs::write(&source, "# New rules").unwrap();
779
780 let imp_config = home.path().join("config");
781 std::fs::create_dir_all(&imp_config).unwrap();
782 std::fs::write(imp_config.join("AGENTS.md"), "# Existing rules").unwrap();
783
784 let detected = DetectedAgentsMd {
785 path: source,
786 kind: AgentsMdKind::AgentsMd,
787 };
788
789 let result = import_agents_md(&detected, &imp_config).unwrap();
790 assert!(result.is_none());
791
792 let content = std::fs::read_to_string(imp_config.join("AGENTS.md")).unwrap();
794 assert_eq!(content, "# Existing rules");
795 }
796
797 #[test]
798 fn extract_description_from_frontmatter() {
799 let content = "---\nname: test\ndescription: A test skill\n---\n\n# Body\n";
800 assert_eq!(extract_skill_description(content), "A test skill");
801 }
802
803 #[test]
804 fn extract_description_multiline() {
805 let content = "---\nname: test\ndescription: >\n Line one\n line two\n---\n";
806 let desc = extract_skill_description(content);
807 assert!(desc.contains("Line one"));
808 }
809
810 #[test]
811 fn extract_description_no_frontmatter() {
812 let content = "# Just a heading\nSome body text.";
813 assert_eq!(extract_skill_description(content), "Some body text.");
814 }
815
816 #[test]
817 fn copy_dir_recursive_works() {
818 let tmp = TempDir::new().unwrap();
819 let src = tmp.path().join("src");
820 let dst = tmp.path().join("dst");
821
822 std::fs::create_dir_all(src.join("sub")).unwrap();
823 std::fs::write(src.join("a.txt"), "hello").unwrap();
824 std::fs::write(src.join("sub").join("b.txt"), "world").unwrap();
825
826 copy_dir_recursive(&src, &dst).unwrap();
827
828 assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
829 assert_eq!(
830 std::fs::read_to_string(dst.join("sub").join("b.txt")).unwrap(),
831 "world"
832 );
833 }
834}