Skip to main content

imp_core/
import.rs

1use std::path::{Path, PathBuf};
2
3/// A detected external agent source that imp can import from.
4#[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/// Which agent tool the source comes from.
13#[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/// A TypeScript extension discovered in another agent's config.
39#[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/// A skill discovered in another agent's config.
56#[derive(Debug, Clone)]
57pub struct DetectedSkill {
58    pub name: String,
59    pub description: String,
60    pub source_path: PathBuf,
61}
62
63/// An AGENTS.md or CLAUDE.md discovered in another agent's config.
64#[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
85/// Scan all known agent sources and return what was found.
86pub 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    // ~/.claude/CLAUDE.md
141    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    // Codex uses ~/.codex/ or project-level AGENTS.md (which imp already reads).
163    // Check for user-level codex config.
164    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    // Parse YAML frontmatter for description field.
321    // Simple string extraction — no YAML parser dependency.
322    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    // Look for "description:" in frontmatter lines
338    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            // Inline value: "description: Some text" or "description: >"
344            let value = rest.trim();
345            if value == ">" || value == "|" {
346                // Multi-line scalar follows
347                in_description = true;
348                continue;
349            }
350            // Single-line value (may be quoted)
351            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                // End of multi-line block
357                break;
358            }
359            if !line.starts_with(' ') && !line.starts_with('\t') {
360                // New key — description block is over
361                break;
362            }
363            if !description.is_empty() {
364                description.push(' ');
365            }
366            description.push_str(trimmed);
367        }
368    }
369
370    description
371}
372
373/// Result of importing skills.
374#[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
386/// Copy skills from a detected source into imp's skill directory.
387pub 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        // Copy the entire skill directory
409        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
421/// Copy TypeScript extensions from a detected source into imp's project extension directory.
422pub 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
464/// Copy an AGENTS.md/CLAUDE.md file into imp's config as AGENTS.md.
465///
466/// Returns the destination path, or None if it already exists.
467pub 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        // pi
650        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        // claude
655        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        // Verify files exist
679        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        // Pre-create the destination
692        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        // Original content preserved
703        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        // Original preserved
793        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}