Skip to main content

skillfile_core/
parser.rs

1use std::collections::HashSet;
2use std::path::Path;
3
4use crate::error::SkillfileError;
5use crate::models::{EntityType, Entry, InstallTarget, Manifest, Scope, SourceFields, DEFAULT_REF};
6
7pub const MANIFEST_NAME: &str = "Skillfile";
8const KNOWN_SOURCES: &[&str] = &["github", "local", "url"];
9
10/// Result of parsing a Skillfile: the manifest plus any warnings.
11#[derive(Debug)]
12pub struct ParseResult {
13    pub manifest: Manifest,
14    pub warnings: Vec<String>,
15}
16
17/// Infer an entry name from a path or URL (filename stem).
18///
19/// ```
20/// use skillfile_core::parser::infer_name;
21/// assert_eq!(infer_name("skills/requesting-code-review"), "requesting-code-review");
22/// assert_eq!(infer_name("agents/code-refactorer.md"), "code-refactorer");
23/// assert_eq!(infer_name("https://example.com/browser-skill.md"), "browser-skill");
24/// assert_eq!(infer_name("."), "content");
25/// ```
26#[must_use]
27pub fn infer_name(path_or_url: &str) -> String {
28    let p = std::path::Path::new(path_or_url);
29    match p.file_stem().and_then(|s| s.to_str()) {
30        Some(stem) if !stem.is_empty() && stem != "." => stem.to_string(),
31        _ => "content".to_string(),
32    }
33}
34
35/// Check if a name is filesystem-safe: alphanumeric, dot, hyphen, underscore.
36fn is_valid_name(name: &str) -> bool {
37    !name.is_empty()
38        && name
39            .chars()
40            .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
41}
42
43/// Split a manifest line respecting double-quoted fields.
44///
45/// Unquoted lines split identically to whitespace split.
46/// Double-quoted fields preserve internal spaces.
47fn split_line(line: &str) -> Vec<String> {
48    let mut parts = Vec::new();
49    let mut current = String::new();
50    let mut in_quotes = false;
51
52    for ch in line.chars() {
53        match ch {
54            '"' => {
55                in_quotes = !in_quotes;
56            }
57            c if c.is_whitespace() && !in_quotes => {
58                if !current.is_empty() {
59                    parts.push(std::mem::take(&mut current));
60                }
61            }
62            c => current.push(c),
63        }
64    }
65    if !current.is_empty() {
66        parts.push(current);
67    }
68    parts
69}
70
71/// Remove inline comment (# ...) from field list.
72fn strip_inline_comment(parts: Vec<String>) -> Vec<String> {
73    if let Some(pos) = parts.iter().position(|p| p.starts_with('#')) {
74        parts[..pos].to_vec()
75    } else {
76        parts
77    }
78}
79
80/// Parse a github entry line. parts[0]=source_type, parts[1]=entity_type, etc.
81fn parse_github_entry(
82    parts: &[String],
83    entity_type: EntityType,
84    lineno: usize,
85) -> (Option<Entry>, Vec<String>) {
86    let mut warnings = Vec::new();
87
88    // Detection: if parts[2] contains '/' → it's owner/repo (inferred name)
89    if parts[2].contains('/') {
90        if parts.len() < 4 {
91            warnings.push(format!(
92                "warning: line {lineno}: github entry needs at least: owner/repo path"
93            ));
94            return (None, warnings);
95        }
96        let owner_repo = &parts[2];
97        let path_in_repo = &parts[3];
98        let ref_ = if parts.len() > 4 {
99            &parts[4]
100        } else {
101            DEFAULT_REF
102        };
103        let name = infer_name(path_in_repo);
104        (
105            Some(Entry {
106                entity_type,
107                name,
108                source: SourceFields::Github {
109                    owner_repo: owner_repo.clone(),
110                    path_in_repo: path_in_repo.clone(),
111                    ref_: ref_.to_string(),
112                },
113            }),
114            warnings,
115        )
116    } else {
117        if parts.len() < 5 {
118            warnings.push(format!(
119                "warning: line {lineno}: github entry needs at least: name owner/repo path"
120            ));
121            return (None, warnings);
122        }
123        let name = &parts[2];
124        let owner_repo = &parts[3];
125        let path_in_repo = &parts[4];
126        let ref_ = if parts.len() > 5 {
127            &parts[5]
128        } else {
129            DEFAULT_REF
130        };
131        (
132            Some(Entry {
133                entity_type,
134                name: name.clone(),
135                source: SourceFields::Github {
136                    owner_repo: owner_repo.clone(),
137                    path_in_repo: path_in_repo.clone(),
138                    ref_: ref_.to_string(),
139                },
140            }),
141            warnings,
142        )
143    }
144}
145
146/// Parse a local entry line.
147fn parse_local_entry(
148    parts: &[String],
149    entity_type: EntityType,
150    lineno: usize,
151) -> (Option<Entry>, Vec<String>) {
152    let mut warnings = Vec::new();
153
154    // Detection: if parts[2] ends in ".md" or contains '/' → path (inferred name)
155    if parts[2].ends_with(".md") || parts[2].contains('/') {
156        let local_path = &parts[2];
157        let name = infer_name(local_path);
158        (
159            Some(Entry {
160                entity_type,
161                name,
162                source: SourceFields::Local {
163                    path: local_path.clone(),
164                },
165            }),
166            warnings,
167        )
168    } else {
169        if parts.len() < 4 {
170            warnings.push(format!(
171                "warning: line {lineno}: local entry needs: name path"
172            ));
173            return (None, warnings);
174        }
175        let name = &parts[2];
176        let local_path = &parts[3];
177        (
178            Some(Entry {
179                entity_type,
180                name: name.clone(),
181                source: SourceFields::Local {
182                    path: local_path.clone(),
183                },
184            }),
185            warnings,
186        )
187    }
188}
189
190/// Parse a url entry line.
191fn parse_url_entry(
192    parts: &[String],
193    entity_type: EntityType,
194    lineno: usize,
195) -> (Option<Entry>, Vec<String>) {
196    let mut warnings = Vec::new();
197
198    // Detection: if parts[2] starts with "http" → URL (inferred name)
199    if parts[2].starts_with("http") {
200        let url = &parts[2];
201        let name = infer_name(url);
202        (
203            Some(Entry {
204                entity_type,
205                name,
206                source: SourceFields::Url { url: url.clone() },
207            }),
208            warnings,
209        )
210    } else {
211        if parts.len() < 4 {
212            warnings.push(format!("warning: line {lineno}: url entry needs: name url"));
213            return (None, warnings);
214        }
215        let name = &parts[2];
216        let url = &parts[3];
217        (
218            Some(Entry {
219                entity_type,
220                name: name.clone(),
221                source: SourceFields::Url { url: url.clone() },
222            }),
223            warnings,
224        )
225    }
226}
227
228/// Process an `install` line, pushing a target or a warning.
229fn parse_install_line(
230    parts: &[String],
231    lineno: usize,
232    install_targets: &mut Vec<InstallTarget>,
233    warnings: &mut Vec<String>,
234) {
235    if parts.len() < 3 {
236        warnings.push(format!(
237            "warning: line {lineno}: install line needs: adapter scope"
238        ));
239        return;
240    }
241    let scope_str = &parts[2];
242    match Scope::parse(scope_str) {
243        Some(scope) => {
244            install_targets.push(InstallTarget {
245                adapter: parts[1].clone(),
246                scope,
247            });
248        }
249        None => {
250            let valid: Vec<&str> = Scope::ALL.iter().map(|s| s.as_str()).collect();
251            warnings.push(format!(
252                "warning: line {lineno}: invalid scope '{scope_str}', \
253                 must be one of: {}",
254                valid.join(", ")
255            ));
256        }
257    }
258}
259
260/// Validate an entry name and insert it into the accumulator, warning on problems.
261fn validate_and_push_entry(
262    entry: Entry,
263    lineno: usize,
264    seen_names: &mut HashSet<String>,
265    entries: &mut Vec<Entry>,
266    warnings: &mut Vec<String>,
267) {
268    if !is_valid_name(&entry.name) {
269        warnings.push(format!(
270            "warning: line {lineno}: invalid name '{}' \
271             — names must match [a-zA-Z0-9._-], skipping",
272            entry.name
273        ));
274    } else if seen_names.contains(&entry.name) {
275        warnings.push(format!(
276            "warning: line {lineno}: duplicate entry name '{}'",
277            entry.name
278        ));
279        entries.push(entry);
280    } else {
281        seen_names.insert(entry.name.clone());
282        entries.push(entry);
283    }
284}
285
286/// Parse a Skillfile manifest from the given path.
287pub fn parse_manifest(manifest_path: &Path) -> Result<ParseResult, SkillfileError> {
288    let raw_bytes = std::fs::read(manifest_path)?;
289
290    // Strip UTF-8 BOM if present
291    let text = if raw_bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
292        String::from_utf8_lossy(&raw_bytes[3..]).into_owned()
293    } else {
294        String::from_utf8_lossy(&raw_bytes).into_owned()
295    };
296
297    let mut entries = Vec::new();
298    let mut install_targets = Vec::new();
299    let mut warnings = Vec::new();
300    let mut seen_names = HashSet::new();
301
302    for (lineno, raw) in text.lines().enumerate() {
303        let lineno = lineno + 1; // 1-indexed
304        let line = raw.trim();
305        if line.is_empty() || line.starts_with('#') {
306            continue;
307        }
308
309        let parts = split_line(line);
310        let parts = strip_inline_comment(parts);
311        if parts.len() < 2 {
312            warnings.push(format!("warning: line {lineno}: too few fields, skipping"));
313            continue;
314        }
315
316        let source_type = &parts[0];
317
318        match source_type.as_str() {
319            "install" => {
320                parse_install_line(&parts, lineno, &mut install_targets, &mut warnings);
321            }
322            st if KNOWN_SOURCES.contains(&st) => {
323                if parts.len() < 3 {
324                    warnings.push(format!("warning: line {lineno}: too few fields, skipping"));
325                    continue;
326                }
327                let entity_type = match EntityType::parse(&parts[1]) {
328                    Some(et) => et,
329                    None => {
330                        warnings.push(format!(
331                            "warning: line {lineno}: unknown entity type '{}', skipping",
332                            parts[1]
333                        ));
334                        continue;
335                    }
336                };
337                let (entry_opt, mut entry_warnings) = match st {
338                    "github" => parse_github_entry(&parts, entity_type, lineno),
339                    "local" => parse_local_entry(&parts, entity_type, lineno),
340                    "url" => parse_url_entry(&parts, entity_type, lineno),
341                    _ => unreachable!(),
342                };
343                warnings.append(&mut entry_warnings);
344                if let Some(entry) = entry_opt {
345                    validate_and_push_entry(
346                        entry,
347                        lineno,
348                        &mut seen_names,
349                        &mut entries,
350                        &mut warnings,
351                    );
352                }
353            }
354            _ => {
355                warnings.push(format!(
356                    "warning: line {lineno}: unknown source type '{source_type}', skipping"
357                ));
358            }
359        }
360    }
361
362    Ok(ParseResult {
363        manifest: Manifest {
364            entries,
365            install_targets,
366        },
367        warnings,
368    })
369}
370
371/// Try to parse a single manifest line as an entry. Returns `Some(entry)` on success.
372#[must_use]
373pub fn parse_manifest_line(line: &str) -> Option<Entry> {
374    let parts = split_line(line);
375    let parts = strip_inline_comment(parts);
376    if parts.len() < 3 {
377        return None;
378    }
379    let source_type = parts[0].as_str();
380    if !KNOWN_SOURCES.contains(&source_type) || source_type == "install" {
381        return None;
382    }
383    let entity_type = EntityType::parse(&parts[1])?;
384    let (entry_opt, _) = match source_type {
385        "github" => parse_github_entry(&parts, entity_type, 0),
386        "local" => parse_local_entry(&parts, entity_type, 0),
387        "url" => parse_url_entry(&parts, entity_type, 0),
388        _ => return None,
389    };
390    entry_opt
391}
392
393/// Find an entry by name in a manifest.
394pub fn find_entry_in<'a>(name: &str, manifest: &'a Manifest) -> Result<&'a Entry, SkillfileError> {
395    manifest
396        .entries
397        .iter()
398        .find(|e| e.name == name)
399        .ok_or_else(|| {
400            SkillfileError::Manifest(format!("no entry named '{name}' in {MANIFEST_NAME}"))
401        })
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use std::fs;
408
409    fn write_manifest(dir: &Path, content: &str) -> std::path::PathBuf {
410        let p = dir.join(MANIFEST_NAME);
411        // Dedent: strip leading whitespace common to all non-empty lines
412        let lines: Vec<&str> = content.lines().collect();
413        let min_indent = lines
414            .iter()
415            .filter(|l| !l.trim().is_empty())
416            .map(|l| l.len() - l.trim_start().len())
417            .min()
418            .unwrap_or(0);
419        let dedented: String = lines
420            .iter()
421            .map(|l| {
422                if l.len() >= min_indent {
423                    &l[min_indent..]
424                } else {
425                    l.trim()
426                }
427            })
428            .collect::<Vec<_>>()
429            .join("\n");
430        fs::write(&p, dedented.trim_start_matches('\n').to_string() + "\n").unwrap();
431        p
432    }
433
434    // -------------------------------------------------------------------
435    // Existing entry types (explicit name + ref)
436    // -------------------------------------------------------------------
437
438    #[test]
439    fn github_entry_explicit_name_and_ref() {
440        let dir = tempfile::tempdir().unwrap();
441        let p = write_manifest(
442            dir.path(),
443            "github  agent  backend-dev  owner/repo  path/to/agent.md  main",
444        );
445        let r = parse_manifest(&p).unwrap();
446        assert_eq!(r.manifest.entries.len(), 1);
447        let e = &r.manifest.entries[0];
448        assert_eq!(e.source_type(), "github");
449        assert_eq!(e.entity_type, EntityType::Agent);
450        assert_eq!(e.name, "backend-dev");
451        assert_eq!(e.owner_repo(), "owner/repo");
452        assert_eq!(e.path_in_repo(), "path/to/agent.md");
453        assert_eq!(e.ref_(), "main");
454    }
455
456    #[test]
457    fn local_entry_explicit_name() {
458        let dir = tempfile::tempdir().unwrap();
459        let p = write_manifest(dir.path(), "local  skill  git-commit  skills/git/commit.md");
460        let r = parse_manifest(&p).unwrap();
461        assert_eq!(r.manifest.entries.len(), 1);
462        let e = &r.manifest.entries[0];
463        assert_eq!(e.source_type(), "local");
464        assert_eq!(e.entity_type, EntityType::Skill);
465        assert_eq!(e.name, "git-commit");
466        assert_eq!(e.local_path(), "skills/git/commit.md");
467    }
468
469    #[test]
470    fn url_entry_explicit_name() {
471        let dir = tempfile::tempdir().unwrap();
472        let p = write_manifest(
473            dir.path(),
474            "url  skill  my-skill  https://example.com/skill.md",
475        );
476        let r = parse_manifest(&p).unwrap();
477        assert_eq!(r.manifest.entries.len(), 1);
478        let e = &r.manifest.entries[0];
479        assert_eq!(e.source_type(), "url");
480        assert_eq!(e.name, "my-skill");
481        assert_eq!(e.url(), "https://example.com/skill.md");
482    }
483
484    // -------------------------------------------------------------------
485    // Optional name inference
486    // -------------------------------------------------------------------
487
488    #[test]
489    fn github_entry_inferred_name() {
490        let dir = tempfile::tempdir().unwrap();
491        let p = write_manifest(
492            dir.path(),
493            "github  agent  owner/repo  path/to/agent.md  main",
494        );
495        let r = parse_manifest(&p).unwrap();
496        assert_eq!(r.manifest.entries.len(), 1);
497        let e = &r.manifest.entries[0];
498        assert_eq!(e.name, "agent");
499        assert_eq!(e.owner_repo(), "owner/repo");
500        assert_eq!(e.path_in_repo(), "path/to/agent.md");
501        assert_eq!(e.ref_(), "main");
502    }
503
504    #[test]
505    fn local_entry_inferred_name_from_path() {
506        let dir = tempfile::tempdir().unwrap();
507        let p = write_manifest(dir.path(), "local  skill  skills/git/commit.md");
508        let r = parse_manifest(&p).unwrap();
509        assert_eq!(r.manifest.entries.len(), 1);
510        let e = &r.manifest.entries[0];
511        assert_eq!(e.name, "commit");
512        assert_eq!(e.local_path(), "skills/git/commit.md");
513    }
514
515    #[test]
516    fn local_entry_inferred_name_from_md_extension() {
517        let dir = tempfile::tempdir().unwrap();
518        let p = write_manifest(dir.path(), "local  skill  commit.md");
519        let r = parse_manifest(&p).unwrap();
520        assert_eq!(r.manifest.entries.len(), 1);
521        assert_eq!(r.manifest.entries[0].name, "commit");
522    }
523
524    #[test]
525    fn url_entry_inferred_name() {
526        let dir = tempfile::tempdir().unwrap();
527        let p = write_manifest(dir.path(), "url  skill  https://example.com/my-skill.md");
528        let r = parse_manifest(&p).unwrap();
529        assert_eq!(r.manifest.entries.len(), 1);
530        let e = &r.manifest.entries[0];
531        assert_eq!(e.name, "my-skill");
532        assert_eq!(e.url(), "https://example.com/my-skill.md");
533    }
534
535    // -------------------------------------------------------------------
536    // Optional ref (defaults to main)
537    // -------------------------------------------------------------------
538
539    #[test]
540    fn github_entry_inferred_name_default_ref() {
541        let dir = tempfile::tempdir().unwrap();
542        let p = write_manifest(dir.path(), "github  agent  owner/repo  path/to/agent.md");
543        let r = parse_manifest(&p).unwrap();
544        assert_eq!(r.manifest.entries[0].ref_(), "main");
545    }
546
547    #[test]
548    fn github_entry_explicit_name_default_ref() {
549        let dir = tempfile::tempdir().unwrap();
550        let p = write_manifest(
551            dir.path(),
552            "github  agent  my-agent  owner/repo  path/to/agent.md",
553        );
554        let r = parse_manifest(&p).unwrap();
555        assert_eq!(r.manifest.entries[0].ref_(), "main");
556    }
557
558    // -------------------------------------------------------------------
559    // Install targets
560    // -------------------------------------------------------------------
561
562    #[test]
563    fn install_target_parsed() {
564        let dir = tempfile::tempdir().unwrap();
565        let p = write_manifest(dir.path(), "install  claude-code  global");
566        let r = parse_manifest(&p).unwrap();
567        assert_eq!(r.manifest.install_targets.len(), 1);
568        let t = &r.manifest.install_targets[0];
569        assert_eq!(t.adapter, "claude-code");
570        assert_eq!(t.scope, Scope::Global);
571    }
572
573    #[test]
574    fn multiple_install_targets() {
575        let dir = tempfile::tempdir().unwrap();
576        let p = write_manifest(
577            dir.path(),
578            "install  claude-code  global\ninstall  claude-code  local",
579        );
580        let r = parse_manifest(&p).unwrap();
581        assert_eq!(r.manifest.install_targets.len(), 2);
582        assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
583        assert_eq!(r.manifest.install_targets[1].scope, Scope::Local);
584    }
585
586    #[test]
587    fn install_targets_not_in_entries() {
588        let dir = tempfile::tempdir().unwrap();
589        let p = write_manifest(
590            dir.path(),
591            "install  claude-code  global\ngithub  agent  owner/repo  path/to/agent.md",
592        );
593        let r = parse_manifest(&p).unwrap();
594        assert_eq!(r.manifest.entries.len(), 1);
595        assert_eq!(r.manifest.install_targets.len(), 1);
596    }
597
598    // -------------------------------------------------------------------
599    // Comments, blanks, errors
600    // -------------------------------------------------------------------
601
602    #[test]
603    fn comments_and_blanks_skipped() {
604        let dir = tempfile::tempdir().unwrap();
605        let p = write_manifest(
606            dir.path(),
607            "# this is a comment\n\n# another comment\nlocal  skill  foo  skills/foo.md",
608        );
609        let r = parse_manifest(&p).unwrap();
610        assert_eq!(r.manifest.entries.len(), 1);
611    }
612
613    #[test]
614    fn malformed_too_few_fields() {
615        let dir = tempfile::tempdir().unwrap();
616        let p = write_manifest(dir.path(), "github  agent");
617        let r = parse_manifest(&p).unwrap();
618        assert!(r.manifest.entries.is_empty());
619        assert!(r.warnings.iter().any(|w| w.contains("warning")));
620    }
621
622    #[test]
623    fn unknown_source_type_skipped() {
624        let dir = tempfile::tempdir().unwrap();
625        let p = write_manifest(dir.path(), "svn  skill  foo  some/path");
626        let r = parse_manifest(&p).unwrap();
627        assert!(r.manifest.entries.is_empty());
628        assert!(r.warnings.iter().any(|w| w.contains("warning")));
629        assert!(r.warnings.iter().any(|w| w.contains("svn")));
630    }
631
632    // -------------------------------------------------------------------
633    // Inline comments
634    // -------------------------------------------------------------------
635
636    #[test]
637    fn inline_comment_stripped() {
638        let dir = tempfile::tempdir().unwrap();
639        let p = write_manifest(
640            dir.path(),
641            "github  agent  owner/repo  agents/foo.md  # my note",
642        );
643        let r = parse_manifest(&p).unwrap();
644        assert_eq!(r.manifest.entries.len(), 1);
645        let e = &r.manifest.entries[0];
646        assert_eq!(e.ref_(), "main"); // not "#"
647        assert_eq!(e.name, "foo");
648    }
649
650    #[test]
651    fn inline_comment_on_install_line() {
652        let dir = tempfile::tempdir().unwrap();
653        let p = write_manifest(dir.path(), "install  claude-code  global  # primary target");
654        let r = parse_manifest(&p).unwrap();
655        assert_eq!(r.manifest.install_targets.len(), 1);
656        assert_eq!(r.manifest.install_targets[0].scope, Scope::Global);
657    }
658
659    #[test]
660    fn inline_comment_after_ref() {
661        let dir = tempfile::tempdir().unwrap();
662        let p = write_manifest(
663            dir.path(),
664            "github  agent  my-agent  owner/repo  agents/foo.md  v1.0  # pinned version",
665        );
666        let r = parse_manifest(&p).unwrap();
667        assert_eq!(r.manifest.entries[0].ref_(), "v1.0");
668    }
669
670    // -------------------------------------------------------------------
671    // Quoted fields
672    // -------------------------------------------------------------------
673
674    #[test]
675    fn quoted_path_with_spaces() {
676        let dir = tempfile::tempdir().unwrap();
677        let p = dir.path().join(MANIFEST_NAME);
678        fs::write(&p, "local  skill  my-skill  \"skills/my dir/foo.md\"\n").unwrap();
679        let r = parse_manifest(&p).unwrap();
680        assert_eq!(r.manifest.entries.len(), 1);
681        assert_eq!(r.manifest.entries[0].local_path(), "skills/my dir/foo.md");
682    }
683
684    #[test]
685    fn quoted_github_path() {
686        let dir = tempfile::tempdir().unwrap();
687        let p = dir.path().join(MANIFEST_NAME);
688        fs::write(
689            &p,
690            "github  skill  owner/repo  \"path with spaces/skill.md\"\n",
691        )
692        .unwrap();
693        let r = parse_manifest(&p).unwrap();
694        assert_eq!(r.manifest.entries.len(), 1);
695        assert_eq!(
696            r.manifest.entries[0].path_in_repo(),
697            "path with spaces/skill.md"
698        );
699    }
700
701    #[test]
702    fn mixed_quoted_and_unquoted() {
703        let dir = tempfile::tempdir().unwrap();
704        let p = dir.path().join(MANIFEST_NAME);
705        fs::write(
706            &p,
707            "github  agent  my-agent  owner/repo  \"agents/path with spaces/foo.md\"\n",
708        )
709        .unwrap();
710        let r = parse_manifest(&p).unwrap();
711        assert_eq!(r.manifest.entries.len(), 1);
712        assert_eq!(r.manifest.entries[0].name, "my-agent");
713        assert_eq!(
714            r.manifest.entries[0].path_in_repo(),
715            "agents/path with spaces/foo.md"
716        );
717    }
718
719    #[test]
720    fn unquoted_fields_parse_identically() {
721        let dir = tempfile::tempdir().unwrap();
722        let p = write_manifest(
723            dir.path(),
724            "github  agent  backend-dev  owner/repo  path/to/agent.md  main",
725        );
726        let r = parse_manifest(&p).unwrap();
727        assert_eq!(r.manifest.entries[0].name, "backend-dev");
728        assert_eq!(r.manifest.entries[0].ref_(), "main");
729    }
730
731    // -------------------------------------------------------------------
732    // Name validation
733    // -------------------------------------------------------------------
734
735    #[test]
736    fn valid_entry_name_accepted() {
737        let dir = tempfile::tempdir().unwrap();
738        let p = write_manifest(dir.path(), "local  skill  my-skill_v2.0  skills/foo.md");
739        let r = parse_manifest(&p).unwrap();
740        assert_eq!(r.manifest.entries.len(), 1);
741        assert_eq!(r.manifest.entries[0].name, "my-skill_v2.0");
742    }
743
744    #[test]
745    fn invalid_entry_name_rejected() {
746        let dir = tempfile::tempdir().unwrap();
747        let p = dir.path().join(MANIFEST_NAME);
748        fs::write(&p, "local  skill  \"my skill!\"  skills/foo.md\n").unwrap();
749        let r = parse_manifest(&p).unwrap();
750        assert!(r.manifest.entries.is_empty());
751        assert!(r
752            .warnings
753            .iter()
754            .any(|w| w.to_lowercase().contains("invalid name")
755                || w.to_lowercase().contains("warning")));
756    }
757
758    #[test]
759    fn inferred_name_validated() {
760        let dir = tempfile::tempdir().unwrap();
761        let p = write_manifest(dir.path(), "local  skill  skills/foo.md");
762        let r = parse_manifest(&p).unwrap();
763        assert_eq!(r.manifest.entries.len(), 1);
764        assert_eq!(r.manifest.entries[0].name, "foo");
765    }
766
767    // -------------------------------------------------------------------
768    // Scope validation
769    // -------------------------------------------------------------------
770
771    #[test]
772    fn valid_scope_accepted() {
773        for (scope_str, expected) in &[("global", Scope::Global), ("local", Scope::Local)] {
774            let dir = tempfile::tempdir().unwrap();
775            let p = write_manifest(dir.path(), &format!("install  claude-code  {scope_str}"));
776            let r = parse_manifest(&p).unwrap();
777            assert_eq!(r.manifest.install_targets.len(), 1);
778            assert_eq!(r.manifest.install_targets[0].scope, *expected);
779        }
780    }
781
782    #[test]
783    fn invalid_scope_rejected() {
784        let dir = tempfile::tempdir().unwrap();
785        let p = write_manifest(dir.path(), "install  claude-code  worldwide");
786        let r = parse_manifest(&p).unwrap();
787        assert!(r.manifest.install_targets.is_empty());
788        assert!(r
789            .warnings
790            .iter()
791            .any(|w| w.to_lowercase().contains("scope") || w.to_lowercase().contains("warning")));
792    }
793
794    // -------------------------------------------------------------------
795    // Duplicate entry name warning
796    // -------------------------------------------------------------------
797
798    #[test]
799    fn duplicate_entry_name_warns() {
800        let dir = tempfile::tempdir().unwrap();
801        let p = write_manifest(
802            dir.path(),
803            "local  skill  foo  skills/foo.md\nlocal  agent  foo  agents/foo.md",
804        );
805        let r = parse_manifest(&p).unwrap();
806        assert_eq!(r.manifest.entries.len(), 2); // both included
807        assert!(r
808            .warnings
809            .iter()
810            .any(|w| w.to_lowercase().contains("duplicate")));
811    }
812
813    // -------------------------------------------------------------------
814    // UTF-8 BOM handling
815    // -------------------------------------------------------------------
816
817    #[test]
818    fn utf8_bom_handled() {
819        let dir = tempfile::tempdir().unwrap();
820        let p = dir.path().join(MANIFEST_NAME);
821        let mut content = vec![0xEF, 0xBB, 0xBF]; // BOM
822        content.extend_from_slice(b"install  claude-code  global\n");
823        fs::write(&p, content).unwrap();
824        let r = parse_manifest(&p).unwrap();
825        assert_eq!(r.manifest.install_targets.len(), 1);
826        assert_eq!(
827            r.manifest.install_targets[0],
828            InstallTarget {
829                adapter: "claude-code".into(),
830                scope: Scope::Global,
831            }
832        );
833    }
834
835    // -------------------------------------------------------------------
836    // Unknown entity type warning
837    // -------------------------------------------------------------------
838
839    #[test]
840    fn unknown_entity_type_skipped_with_warning() {
841        let dir = tempfile::tempdir().unwrap();
842        let p = write_manifest(dir.path(), "local  hook  foo  hooks/foo.md");
843        let r = parse_manifest(&p).unwrap();
844        assert!(r.manifest.entries.is_empty());
845        assert!(r.warnings.iter().any(|w| w.contains("unknown entity type")));
846    }
847
848    // -------------------------------------------------------------------
849    // find_entry_in
850    // -------------------------------------------------------------------
851
852    #[test]
853    fn find_entry_in_found() {
854        let e = Entry {
855            entity_type: EntityType::Skill,
856            name: "foo".into(),
857            source: SourceFields::Local {
858                path: "foo.md".into(),
859            },
860        };
861        let m = Manifest {
862            entries: vec![e.clone()],
863            install_targets: vec![],
864        };
865        assert_eq!(find_entry_in("foo", &m).unwrap(), &e);
866    }
867
868    #[test]
869    fn find_entry_in_not_found() {
870        let m = Manifest::default();
871        assert!(find_entry_in("missing", &m).is_err());
872    }
873
874    // -------------------------------------------------------------------
875    // infer_name
876    // -------------------------------------------------------------------
877
878    #[test]
879    fn infer_name_from_md_path() {
880        assert_eq!(infer_name("path/to/agent.md"), "agent");
881    }
882
883    #[test]
884    fn infer_name_from_dot() {
885        assert_eq!(infer_name("."), "content");
886    }
887
888    #[test]
889    fn infer_name_from_url() {
890        assert_eq!(infer_name("https://example.com/my-skill.md"), "my-skill");
891    }
892
893    // -------------------------------------------------------------------
894    // split_line
895    // -------------------------------------------------------------------
896
897    #[test]
898    fn split_line_simple() {
899        assert_eq!(
900            split_line("github  agent  owner/repo  agent.md"),
901            vec!["github", "agent", "owner/repo", "agent.md"]
902        );
903    }
904
905    #[test]
906    fn split_line_quoted() {
907        assert_eq!(
908            split_line("local  skill  \"my dir/foo.md\""),
909            vec!["local", "skill", "my dir/foo.md"]
910        );
911    }
912
913    #[test]
914    fn split_line_tabs() {
915        assert_eq!(
916            split_line("local\tskill\tfoo.md"),
917            vec!["local", "skill", "foo.md"]
918        );
919    }
920}